Merge remote-tracking branch 'origin/master' into pr-4779-local

# Conflicts:
#	go.mod
#	go.sum
#	pkg/agent/worker.go
This commit is contained in:
Paul Wells
2026-09-13 15:27:20 -07:00
102 changed files with 4736 additions and 1894 deletions
+61
View File
@@ -2,6 +2,67 @@
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.13.6] - 2026-08-26
### Added
- Expand room details in webhook events (#4730)
- Add configurable read-message size limit on signalling WebSockets (#4743)
- Add per-participant concurrent TURN allocation quota (#4744)
- Limit number of pending tracks per participant. (#4750)
- Limit API request body size (#4757)
- Experimental WARP (#4649)
- Log invalid APIKey on API failures. (#4762)
- Add a small cache for data messages received via SendData API. (#4781)
- Add bytes cap on migration data cache size (#4782)
- TEL-912 Adding response for TransferSIPParticipant rpc (#4783)
### Changed
- Remove H.264 baseline (42001f) from default enabled codecs (#4723)
- Apply ICE preference when switching to TCP on unstable UDP (#4703)
- Include data track susbcriptions in WaitForSubscription. (#4727)
- Return incompatible in SetCodecWithState if the codec PT changed (#4729)
- return psrpc.FailedPrecondition for "participant client version does not support moving" error (#4736)
- Update actions/setup-go action to v7 (#4720)
- Validate TURN config to guard against invalid values (#4742)
- Use request id to make api idempotence on sdk retry (#4694)
- Set relay quota per participant at 12 default for dual peer connection + resume scenarios (#4745)
- Record publish time on participant close for pending tracks. (#4738)
- Make subscription limit log Debugw as it could spam in a large room. (#4748)
- Fail server start up on partial prom config. (#4749)
- sample codec payload mismatch error log (#4751)
- Process NACK retransmissions in a single worker per DownTrack (#4758)
- Update module github.com/moby/moby/client to v0.5.1 (#4769)
- Update renovate and pinning behavior, run tools from go.mod (#4759)
- Bump github.com/cilium/ebpf in the go_modules group across 1 directory (#4770)
- bump protocol for passthrough + UpdateEgress removal (#4774)
- Check for ICE connection before closing participant on signal close. (#4780)
- Join failure is a canceled connection attempt. (#4788)
- Update github.com/livekit/mediatransportutil digest to f234b53 (#4785)
- Reduce locking in media track + telemetry listener on move participant. (#4790)
- Group livekit dependency updates in renovate (#4791)
- Return created ingress info from io service (#4787)
- config: clarify AdvertiseInternalIP applies to explicit node_ip too (#4754)
### Fixed
- Fix AgentHandler.DrainConnections deadlock on worker close. (#4710)
- Check for pictureID existence in VP8 and VP9 (#4721)
- Do not report end time for participant if the participant is migrating (#4728)
- Check layer value in dependency descriptor and keep it in bounds. (#4739)
- Fix publish track count on migration in. (#4740)
- Cover a couple of more cases on data track runt packet handling. (#4741)
- Close web socket connections in all paths. (#4747)
- Check slice length before access in a couple of more places (#4752)
- Remove auth token from log/being sent back to client on invalid token error (#4756)
- Flush sequencer on stream restart; bound frame-integrity loops (#4760)
- Redact stream keys in UpdateStream API log fields (#4763)
- Fix deadlock in regress codec check. (#4775)
- Flush pending signal responses before closing the web socket. (#4776)
- Strip packet trailers from every VP9 layer frame (#4773)
- Fix simulcast RTX pairing broken by pion/webrtc#3470 (#4800)
## [1.13.5] - 2026-07-31
### Added
+1 -1
View File
@@ -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.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
ARG TARGETPLATFORM
ARG TARGETARCH
+81 -164
View File
@@ -8,10 +8,12 @@
<!--END_BANNER_IMAGE-->
# 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)
<!--BEGIN_AGENTS_INFO-->
> [!IMPORTANT]
> If you're building Voice AI, [LiveKit Agents](https://github.com/livekit/agents) is the SDK for code-first realtime voice agents. STT, LLM, TTS, turn detection, [expressive speech](https://docs.livekit.io/agents/models/tts/expressive/), [keyterm accuracy](https://docs.livekit.io/agents/models/stt/keyterms/), tool usage, and telephony all come bundled in the framework. It's available in both [Python](https://github.com/livekit/agents) and [Node.js](https://github.com/livekit/agents-js).
>
> ```python
> # agent.py
> from livekit import agents
> from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference
>
> server = AgentServer()
>
>
> @server.rtc_session(agent_name="my-agent")
> async def my_agent(ctx: agents.JobContext):
> session = AgentSession(
> stt=inference.STT(model="deepgram/nova-3", language="multi"),
> llm=inference.LLM(model="google/gemma-4-31b-it"),
> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"),
> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()),
> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]),
> expressive=True,
> )
> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant."))
> await session.generate_reply(instructions="Greet the user and offer your assistance.")
>
>
> if __name__ == "__main__":
> agents.cli.run_app(server)
> ```
>
> Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/).
<!--END_AGENTS_INFO-->
## Used in production by
LiveKit carries billions of calls a year for companies including Salesforce, Nvidia, Oracle, SAP,
Deutsche Telekom, Spotify, Tinder, Coursera, Headspace, Skydio, Retell, Decagon, Cresta, and HeyGen. Read how
[Assort Health](https://livekit.com/customers/assort-health), [Playback](https://livekit.com/customers/playback), and
[Polymath Robotics](https://livekit.com/customers/polymath) use it, or see [more customers](https://livekit.com/customers).
## Features
- Scalable, distributed WebRTC SFU (Selective Forwarding Unit)
- 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.
<table>
<tr>
<th>Language</th>
<th>Repo</th>
<th>
<a href="https://docs.livekit.io/home/client/events/#declarative-ui" target="_blank" rel="noopener noreferrer">Declarative UI</a>
</th>
<th>Links</th>
</tr>
<!-- BEGIN Template
<tr>
<td>Language</td>
<td>
<a href="" target="_blank" rel="noopener noreferrer"></a>
</td>
<td></td>
<td></td>
</tr>
END -->
<!-- JavaScript -->
<tr>
<td>JavaScript (TypeScript)</td>
<td>
<a href="https://github.com/livekit/client-sdk-js" target="_blank" rel="noopener noreferrer">client-sdk-js</a>
</td>
<td>
<a href="https://github.com/livekit/livekit-react" target="_blank" rel="noopener noreferrer">React</a>
</td>
<td>
<a href="https://docs.livekit.io/client-sdk-js/" target="_blank" rel="noopener noreferrer">docs</a>
|
<a href="https://github.com/livekit/client-sdk-js/tree/main/example" target="_blank" rel="noopener noreferrer">JS example</a>
|
<a href="https://github.com/livekit/client-sdk-js/tree/main/example" target="_blank" rel="noopener noreferrer">React example</a>
</td>
</tr>
<!-- Swift -->
<tr>
<td>Swift (iOS / MacOS)</td>
<td>
<a href="https://github.com/livekit/client-sdk-swift" target="_blank" rel="noopener noreferrer">client-sdk-swift</a>
</td>
<td>Swift UI</td>
<td>
<a href="https://docs.livekit.io/client-sdk-swift/" target="_blank" rel="noopener noreferrer">docs</a>
|
<a href="https://github.com/livekit/client-example-swift" target="_blank" rel="noopener noreferrer">example</a>
</td>
</tr>
<!-- Kotlin -->
<tr>
<td>Kotlin (Android)</td>
<td>
<a href="https://github.com/livekit/client-sdk-android" target="_blank" rel="noopener noreferrer">client-sdk-android</a>
</td>
<td>Compose</td>
<td>
<a href="https://docs.livekit.io/client-sdk-android/index.html" target="_blank" rel="noopener noreferrer">docs</a>
|
<a href="https://github.com/livekit/client-sdk-android/tree/main/sample-app/src/main/java/io/livekit/android/sample" target="_blank" rel="noopener noreferrer">example</a>
|
<a href="https://github.com/livekit/client-sdk-android/tree/main/sample-app-compose/src/main/java/io/livekit/android/composesample" target="_blank" rel="noopener noreferrer">Compose example</a>
</td>
</tr>
<!-- Flutter -->
<tr>
<td>Flutter (all platforms)</td>
<td>
<a href="https://github.com/livekit/client-sdk-flutter" target="_blank" rel="noopener noreferrer">client-sdk-flutter</a>
</td>
<td>native</td>
<td>
<a href="https://docs.livekit.io/client-sdk-flutter/" target="_blank" rel="noopener noreferrer">docs</a>
|
<a href="https://github.com/livekit/client-sdk-flutter/tree/main/example" target="_blank" rel="noopener noreferrer">example</a>
</td>
</tr>
<!-- Unity -->
<tr>
<td>Unity WebGL</td>
<td>
<a href="https://github.com/livekit/client-sdk-unity-web" target="_blank" rel="noopener noreferrer">client-sdk-unity-web</a>
</td>
<td></td>
<td>
<a href="https://livekit.github.io/client-sdk-unity-web/" target="_blank" rel="noopener noreferrer">docs</a>
</td>
</tr>
<!-- React Native -->
<tr>
<td>React Native (beta)</td>
<td>
<a href="https://github.com/livekit/client-sdk-react-native" target="_blank" rel="noopener noreferrer">client-sdk-react-native</a>
</td>
<td>native</td>
<td></td>
</tr>
<!-- Rust -->
<tr>
<td>Rust</td>
<td>
<a href="https://github.com/livekit/client-sdk-rust" target="_blank" rel="noopener noreferrer">client-sdk-rust</a>
</td>
<td></td>
<td></td>
</tr>
</table>
### 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
+1 -1
View File
@@ -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.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
ARG TARGETARCH
+1 -2
View File
@@ -24,7 +24,6 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/utils/protojson"
@@ -107,7 +106,7 @@ func init() {
reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule")
reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule")
reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant")
reg[livekit.TransferSIPParticipantRequest, emptypb.Empty]("livekit.SIP/TransferSIPParticipant")
reg[livekit.TransferSIPParticipantRequest, livekit.TransferSIPParticipantResponse]("livekit.SIP/TransferSIPParticipant")
// Connector
reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall")
+44 -1
View File
@@ -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{
+33 -1
View File
@@ -67,7 +67,8 @@ rtc:
use_external_ip: true
# # when set to true, advertises both mapped external and internal IPs to clients as server candidates.
# # useful when clients connect from both private and public networks.
# # works only when `use_external_ip` is set to true.
# # works when `use_external_ip` is set to true, and also when `node_ip` is set explicitly below.
# # in both cases the node's local candidate is kept alongside the mapped one instead of being replaced by it.
# # when both this and `external_ip_only` are set, SFU advertises all private IPs with their mapped external IPs and skips
# # private IPs that do not have a mapped external IP.
# advertise_internal_ip: true
@@ -267,6 +268,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:
@@ -307,6 +317,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)
@@ -336,6 +359,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
+35 -35
View File
@@ -1,6 +1,6 @@
module github.com/livekit/livekit-server
go 1.26
go 1.26.0
require (
github.com/bep/debounce v1.2.1
@@ -20,9 +20,9 @@ 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-20260608063931-a3417d38cda0
github.com/livekit/protocol v1.50.5-0.20260831015717-ba22cc1ce840
github.com/livekit/psrpc v0.7.3
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f
github.com/livekit/psrpc v0.7.7
github.com/mackerelio/go-osstat v0.2.8
github.com/magefile/mage v1.17.2
github.com/mitchellh/go-homedir v1.1.0
@@ -38,15 +38,16 @@ require (
github.com/pion/sctp v1.11.1
github.com/pion/sdp/v3 v3.0.19
github.com/pion/transport/v4 v4.1.0
github.com/pion/turn/v5 v5.0.12
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.11.1
github.com/stretchr/testify v1.12.1
github.com/thoas/go-funk v0.9.3
github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e
github.com/twitchtv/twirp v8.1.3+incompatible
@@ -55,13 +56,14 @@ require (
go.uber.org/atomic v1.11.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.28.0
golang.org/x/mod v0.38.0
golang.org/x/mod v0.40.0
golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
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
@@ -85,80 +87,78 @@ require (
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-20260725062400-500c67a39b75 // indirect
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // 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.45.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.45.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.45.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/time v0.15.0 // indirect
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect
buf.build/go/protovalidate v1.2.0 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 // indirect
buf.build/go/protovalidate v1.4.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
cel.dev/expr v0.25.2 // indirect
cel.dev/expr v0.25.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/google/cel-go v0.30.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.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/lithammer/shortuuid/v4 v4.3.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.6.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.52.0 // 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.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/srtp/v3 v3.0.13 // indirect
github.com/pion/stun/v3 v3.1.6
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/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/urfave/cli/v3 v3.10.1
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.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
google.golang.org/grpc v1.83.2 // indirect
)
tool (
@@ -172,4 +172,4 @@ replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-w
replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1
replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.1
replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.2
+68 -80
View File
@@ -1,11 +1,13 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 h1:Slv0uGxx219srASyiaI5C9cDlyG8kNDcXpTSYcuAeE4=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@@ -44,8 +46,6 @@ github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -54,8 +54,6 @@ 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=
@@ -87,8 +85,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.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
github.com/google/cel-go v0.30.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=
@@ -143,8 +139,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.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -158,20 +154,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
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/ice/v4 v4.4.0-warp.2 h1:AYfwxksKNs2LGgLIifsrB/K4ixaQDuURxBgrA5dJBo4=
github.com/livekit/ice/v4 v4.4.0-warp.2/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.50.5-0.20260831015717-ba22cc1ce840 h1:VGdD4zhgRk5o/J+CPX/qT9lU/7l78eVwW8SPvrKBP8I=
github.com/livekit/protocol v1.50.5-0.20260831015717-ba22cc1ce840/go.mod h1:/kYxa0dlTuH981LaBFHG/Swyr969d0+2+/6Lm7fFc34=
github.com/livekit/psrpc v0.7.3 h1:bekuZt/ZQzg8+/M8G6G5jq7bvV9fAKdPHSOZeTwrIIc=
github.com/livekit/psrpc v0.7.3/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc=
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs=
github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4=
github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo=
github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s=
github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc=
github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4=
@@ -215,8 +211,8 @@ github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJ
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -239,16 +235,16 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 h1:VmZ6mKVkxavKEhEy4ZYyV7BwBYBFBP0TwIqmLk84fpU=
github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.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.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA=
github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
@@ -261,14 +257,16 @@ github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q=
github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
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=
@@ -276,22 +274,14 @@ 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.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/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=
@@ -307,8 +297,8 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw=
github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA=
@@ -331,28 +321,26 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
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.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
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.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
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.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/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=
@@ -365,12 +353,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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -385,8 +373,8 @@ 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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -416,33 +404,33 @@ golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBc
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-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+8
View File
@@ -160,6 +160,13 @@ type WorkerRegistration struct {
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
// observability). This is server-controlled and never populated from the
// worker's register request.
KindDetails []livekit.ParticipantInfo_KindDetail
}
func MakeWorkerRegistration() WorkerRegistration {
@@ -441,6 +448,7 @@ func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook Assignmen
res.ParticipantMetadata,
attributes,
w.Permissions,
w.KindDetails...,
)
if err != nil {
w.logger.Errorw("failed to build agent token", err)
+27
View File
@@ -279,6 +279,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
@@ -439,6 +452,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{}
@@ -572,6 +598,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,
},
+21
View File
@@ -61,6 +61,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:
+5 -13
View File
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
package selector
import "github.com/livekit/protocol/livekit"
func FilterNodesByCriteria(nodes []*livekit.Node, criteriaThreshold float32, calculateCriteriaFunc func(*livekit.Node) float32) ([]*livekit.Node, error) {
nodes = GetAvailableNodes(nodes)
if len(nodes) == 0 {
return nil, ErrNoAvailableNodes
}
filteredNodes := make([]*livekit.Node, 0)
for _, node := range nodes {
if calculateCriteriaFunc(node) < criteriaThreshold {
filteredNodes = append(filteredNodes, node)
}
}
if len(filteredNodes) > 0 {
nodes = filteredNodes
}
return nodes, nil
}
+3 -12
View File
@@ -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
}
+1 -1
View File
@@ -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"
)
+2 -2
View File
@@ -21,9 +21,9 @@ import (
"github.com/frostbyte73/core"
"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"
@@ -127,7 +127,7 @@ func (d *DataTrack) AddSubscriber(sub types.LocalParticipant) (types.DataDownTra
sub.ID(),
sub.Kind(),
sub.KindDetails(),
sub.GetTelemetryListener(),
sub.GetTelemetryListener,
sub.GetReporter(),
)
dataDownTrack, err := NewDataDownTrack(DataDownTrackParams{
@@ -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
}
@@ -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())
}
-310
View File
@@ -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
}
-294
View File
@@ -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)
})
}
-74
View File
@@ -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
}
+1 -1
View File
@@ -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"
)
+48 -87
View File
@@ -46,7 +46,7 @@ var _ types.LocalMediaTrack = (*MediaTrack)(nil)
// Implements MediaTrack and PublishedTrack interface
type MediaTrack struct {
params MediaTrackParams
buffer *buffer.Buffer
buffer atomic.Pointer[buffer.Buffer]
everSubscribed atomic.Bool
*MediaTrackReceiver
@@ -54,26 +54,20 @@ type MediaTrack struct {
dynacastManager dynacast.DynacastManager
lock sync.RWMutex
migrated bool
published bool
// serialises receiver creation in AddReceiver, i. e. the check-then-create
// of a receiver for a mime type. Held across receiver set up
addReceiverLock sync.Mutex
regressionTargetCodecReceived atomic.Bool
migrated atomic.Bool
published atomic.Bool
rttFromXR atomic.Bool
backupCodecPolicy livekit.BackupCodecPolicy
regressionTargetCodec mime.MimeType
regressionTargetCodecReceived bool
onSubscribedMaxQualityChange func(
trackID livekit.TrackID,
trackInfo *livekit.TrackInfo,
subscribedQualities []*livekit.SubscribedCodec,
maxSubscribedQualities []types.SubscribedCodecQuality,
) error
onSubscribedAudioCodecChange func(
trackID livekit.TrackID,
codecs []*livekit.SubscribedAudioCodec,
) error
backupCodecPolicy livekit.BackupCodecPolicy
// immutable after construction
regressionTargetCodec mime.MimeType
}
type MediaTrackParams struct {
@@ -89,7 +83,7 @@ type MediaTrackParams struct {
PLIThrottleConfig sfu.PLIThrottleConfig
AudioConfig sfu.AudioConfig
VideoConfig config.VideoConfig
TelemetryListener types.ParticipantTelemetryListener
TelemetryListener func() types.ParticipantTelemetryListener
Logger logger.Logger
Reporter roomobs.TrackReporter
SimTracks map[uint32]interceptor.SimulcastTrackInfo
@@ -101,6 +95,16 @@ type MediaTrackParams struct {
EnableRTPStreamRestartDetection bool
UpdateTrackInfoByVideoSizeChange bool
ForceBackupCodecPolicySimulcast bool
OnSubscribedMaxQualityChange func(
trackID livekit.TrackID,
trackInfo *livekit.TrackInfo,
subscribedQualities []*livekit.SubscribedCodec,
maxSubscribedQualities []types.SubscribedCodecQuality,
) error
OnSubscribedAudioCodecChange func(
trackID livekit.TrackID,
codecs []*livekit.SubscribedAudioCodec,
) error
}
func NewMediaTrack(params MediaTrackParams, ti *livekit.TrackInfo) *MediaTrack {
@@ -138,8 +142,8 @@ func NewMediaTrack(params MediaTrackParams, ti *livekit.TrackInfo) *MediaTrack {
Logger: params.Logger,
})
t.MediaLossProxy.OnMediaLossUpdate(func(fractionalLoss uint8) {
if t.buffer != nil {
t.buffer.SetLastFractionLostReport(fractionalLoss)
if buff := t.buffer.Load(); buff != nil {
buff.SetLastFractionLostReport(fractionalLoss)
}
})
t.MediaTrackReceiver.OnMediaLossFeedback(t.MediaLossProxy.HandleMaxLossFeedback)
@@ -200,30 +204,6 @@ func NewMediaTrack(params MediaTrackParams, ti *livekit.TrackInfo) *MediaTrack {
return t
}
func (t *MediaTrack) OnSubscribedMaxQualityChange(
f func(
trackID livekit.TrackID,
trackInfo *livekit.TrackInfo,
subscribedQualities []*livekit.SubscribedCodec,
maxSubscribedQualities []types.SubscribedCodecQuality,
) error,
) {
t.lock.Lock()
t.onSubscribedMaxQualityChange = f
t.lock.Unlock()
}
func (t *MediaTrack) OnSubscribedAudioCodecChange(
f func(
trackID livekit.TrackID,
codecs []*livekit.SubscribedAudioCodec,
) error,
) {
t.lock.Lock()
t.onSubscribedAudioCodecChange = f
t.lock.Unlock()
}
func (t *MediaTrack) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, qualities []types.SubscribedCodecQuality) {
if t.dynacastManager != nil {
t.dynacastManager.NotifySubscriberNodeMaxQuality(nodeID, qualities)
@@ -348,8 +328,11 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
})
ti := t.MediaTrackReceiver.TrackInfoClone()
t.lock.Lock()
var regressCodec bool
enableRegression := t.enableRegression()
t.addReceiverLock.Lock()
mimeType := mime.NormalizeMimeType(track.Codec().MimeType)
layer := buffer.GetSpatialLayerForRid(mimeType, track.RID(), ti)
if layer < 0 {
@@ -361,7 +344,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
"codec", track.Codec(),
"trackInfo", logger.Proto(ti),
)
t.lock.Unlock()
t.addReceiverLock.Unlock()
return newCodec, false
}
@@ -405,7 +388,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
"mime", mimeType,
"track", logger.Proto(ti),
)
t.lock.Unlock()
t.addReceiverLock.Unlock()
return newCodec, false
}
@@ -448,11 +431,8 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
}
newWR.OnStatsUpdate(func(_ *sfu.WebRTCReceiver, stat *livekit.AnalyticsStat) {
// send for only one codec, either primary (priority == 0) OR regressed codec
t.lock.RLock()
regressionTargetCodecReceived := t.regressionTargetCodecReceived
t.lock.RUnlock()
if priority == 0 || regressionTargetCodecReceived {
t.params.TelemetryListener.OnTrackStats(statsKey, stat)
if priority == 0 || t.regressionTargetCodecReceived.Load() {
t.params.TelemetryListener().OnTrackStats(statsKey, stat)
if cs, ok := telemetry.CondenseStat(stat); ok {
t.params.Reporter.Tx(func(tx roomobs.TrackTx) {
@@ -477,10 +457,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
newWR.OnMaxLayerChange(func(mimeType mime.MimeType, maxLayer int32) {
// send for only one codec, either primary (priority == 0) OR regressed codec
t.lock.RLock()
regressionTargetCodecReceived := t.regressionTargetCodecReceived
t.lock.RUnlock()
if priority == 0 || regressionTargetCodecReceived {
if priority == 0 || t.regressionTargetCodecReceived.Load() {
t.MediaTrackReceiver.NotifyMaxLayerChange(mimeType, maxLayer)
}
})
@@ -505,7 +482,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
}
}
t.buffer = buff
t.buffer.Store(buff)
t.MediaTrackReceiver.SetupReceiver(newWR, priority, mid)
@@ -531,16 +508,16 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
})
}
if newCodec && t.enableRegression() {
if newCodec && enableRegression {
if mimeType == t.regressionTargetCodec {
t.params.Logger.Infow("regression target codec received", "codec", mimeType)
t.regressionTargetCodecReceived = true
t.regressionTargetCodecReceived.Store(true)
regressCodec = true
} else if t.regressionTargetCodecReceived {
} else if t.regressionTargetCodecReceived.Load() {
regressCodec = true
}
}
t.lock.Unlock()
t.addReceiverLock.Unlock()
if err := wr.(*sfu.WebRTCReceiver).AddUpTrack(track, buff); err != nil {
t.params.Logger.Warnw(
@@ -597,7 +574,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe
})
buff.OnFinalRtpStats(func(stats *livekit.RTPStats) {
t.params.TelemetryListener.OnTrackPublishRTPStats(
t.params.TelemetryListener().OnTrackPublishRTPStats(
t.params.ParticipantID(),
t.ID(),
mimeType,
@@ -678,12 +655,8 @@ func (t *MediaTrack) OnDynacastSubscribedMaxQualityChange(
subscribedQualities []*livekit.SubscribedCodec,
maxSubscribedQualities []types.SubscribedCodecQuality,
) {
t.lock.RLock()
onSubscribedMaxQualityChange := t.onSubscribedMaxQualityChange
t.lock.RUnlock()
if onSubscribedMaxQualityChange != nil && !t.IsMuted() {
_ = onSubscribedMaxQualityChange(
if t.params.OnSubscribedMaxQualityChange != nil && !t.IsMuted() {
_ = t.params.OnSubscribedMaxQualityChange(
t.ID(),
t.ToProto(),
subscribedQualities,
@@ -706,35 +679,23 @@ func (t *MediaTrack) OnDynacastSubscribedMaxQualityChange(
}
func (t *MediaTrack) OnDynacastSubscribedAudioCodecChange(codecs []*livekit.SubscribedAudioCodec) {
t.lock.RLock()
onSubscribedAudioCodecChange := t.onSubscribedAudioCodecChange
t.lock.RUnlock()
if onSubscribedAudioCodecChange != nil {
_ = onSubscribedAudioCodecChange(t.ID(), codecs)
if t.params.OnSubscribedAudioCodecChange != nil {
_ = t.params.OnSubscribedAudioCodecChange(t.ID(), codecs)
}
}
func (t *MediaTrack) SetMigrated(migrated bool) {
t.lock.Lock()
t.migrated = migrated
t.lock.Unlock()
t.migrated.Store(migrated)
}
func (t *MediaTrack) Migrated() bool {
t.lock.RLock()
defer t.lock.RUnlock()
return t.migrated
return t.migrated.Load()
}
func (t *MediaTrack) SetPublished(published bool) {
t.lock.Lock()
t.published = published
t.lock.Unlock()
t.published.Store(published)
}
func (t *MediaTrack) Published() bool {
t.lock.RLock()
defer t.lock.RUnlock()
return t.published
return t.published.Load()
}
+6 -6
View File
@@ -125,7 +125,7 @@ type MediaTrackReceiverParams struct {
ReceiverConfig ReceiverConfig
SubscriberConfig DirectionConfig
AudioConfig sfu.AudioConfig
TelemetryListener types.ParticipantTelemetryListener
TelemetryListener func() types.ParticipantTelemetryListener
Logger logger.Logger
RegressionTargetCodec mime.MimeType
PreferVideoSizeFromMedia bool
@@ -344,7 +344,7 @@ func (t *MediaTrackReceiver) SetPotentialCodecs(codecs []webrtc.RTPCodecParamete
}
if !exist {
receivers = append(receivers, &simulcastReceiver{
TrackReceiver: NewDummyReceiver(t.ID(), string(t.PublisherID()), c, headers),
TrackReceiver: NewDummyReceiver(t.TrackInfo(), string(t.PublisherID()), c, headers),
priority: i,
})
}
@@ -949,7 +949,7 @@ func (t *MediaTrackReceiver) UpdateAudioTrack(update *livekit.UpdateLocalAudioTr
t.updateTrackInfoOfReceivers()
t.params.TelemetryListener.OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.TelemetryListener().OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.Logger.Debugw("updated audio track", "before", logger.Proto(trackInfo), "after", logger.Proto(clonedInfo))
}
@@ -973,7 +973,7 @@ func (t *MediaTrackReceiver) UpdateVideoTrack(update *livekit.UpdateLocalVideoTr
t.updateTrackInfoOfReceivers()
t.params.TelemetryListener.OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.TelemetryListener().OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.Logger.Debugw("updated video track", "before", logger.Proto(trackInfo), "after", logger.Proto(clonedInfo))
}
@@ -1019,7 +1019,7 @@ func (t *MediaTrackReceiver) UpdateVideoSize(mimeType mime.MimeType, sizes []cod
t.updateTrackInfoOfReceivers()
t.params.TelemetryListener.OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.TelemetryListener().OnTrackPublishedUpdate(t.PublisherID(), clonedInfo)
t.params.Logger.Debugw("updated video sizes", "before", logger.Proto(trackInfo), "after", logger.Proto(clonedInfo))
}
@@ -1050,7 +1050,7 @@ func (t *MediaTrackReceiver) NotifyMaxLayerChange(mimeType mime.MimeType, maxLay
}
}
t.params.TelemetryListener.OnTrackPublishedUpdate(t.PublisherID(), ti)
t.params.TelemetryListener().OnTrackPublishedUpdate(t.PublisherID(), ti)
}
// GetQualityForDimension finds the closest quality to use for desired dimensions
+14 -4
View File
@@ -14,9 +14,14 @@ const (
MigrationDataCacheStateDone
)
const (
migrationDataCacheMaxSize = 4 << 20 // 4 MiB
)
type MigrationDataCache struct {
lastSeq uint32
pkts []*livekit.DataPacket
size int
state MigrationDataCacheState
expiredAt time.Time
}
@@ -30,10 +35,10 @@ func NewMigrationDataCache(lastSeq uint32, expiredAt time.Time) *MigrationDataCa
// Add adds a message to the cache if there is a gap between the last sequence number and cached messages then return the cache State:
// - MigrationDataCacheStateWaiting: waiting for the next packet (lastSeq + 1) of last sequence from old node
// - MigrationDataCacheStateTimeout: the next packet is not received before the expiredAt, participant will
// continue to process the reliable messages, subscribers will see the gap after the publisher migration
// - MigrationDataCacheStateTimeout: the next packet is not received before the expiredAt or the cache is full, participant
// will continue to process the reliable messages, subscribers will see the gap after the publisher migration
// - MigrationDataCacheStateDone: the next packet is received, participant can continue to process the reliable messages
func (c *MigrationDataCache) Add(pkt *livekit.DataPacket) MigrationDataCacheState {
func (c *MigrationDataCache) Add(pkt *livekit.DataPacket, size int) MigrationDataCacheState {
if c.state == MigrationDataCacheStateDone || c.state == MigrationDataCacheStateTimeout {
return c.state
}
@@ -48,7 +53,8 @@ func (c *MigrationDataCache) Add(pkt *livekit.DataPacket) MigrationDataCacheStat
}
c.pkts = append(c.pkts, pkt)
if time.Now().After(c.expiredAt) {
c.size += size
if c.size >= migrationDataCacheMaxSize || time.Now().After(c.expiredAt) {
c.state = MigrationDataCacheStateTimeout
}
return c.state
@@ -57,3 +63,7 @@ func (c *MigrationDataCache) Add(pkt *livekit.DataPacket) MigrationDataCacheStat
func (c *MigrationDataCache) Get() []*livekit.DataPacket {
return c.pkts
}
func (c *MigrationDataCache) Size() int {
return c.size
}
+23 -4
View File
@@ -14,25 +14,44 @@ func TestMigrationDataCache_Add(t *testing.T) {
cache := NewMigrationDataCache(10, expiredAt)
pkt1 := &livekit.DataPacket{Sequence: 9}
state := cache.Add(pkt1)
state := cache.Add(pkt1, 0)
require.Equal(t, MigrationDataCacheStateWaiting, state)
require.Empty(t, cache.Get())
pkt2 := &livekit.DataPacket{Sequence: 11}
state = cache.Add(pkt2)
state = cache.Add(pkt2, 0)
require.Equal(t, MigrationDataCacheStateDone, state)
require.Empty(t, cache.Get())
pkt3 := &livekit.DataPacket{Sequence: 12}
state = cache.Add(pkt3)
state = cache.Add(pkt3, 0)
require.Equal(t, MigrationDataCacheStateDone, state)
require.Empty(t, cache.Get())
cache2 := NewMigrationDataCache(20, time.Now().Add(10*time.Millisecond))
pkt4 := &livekit.DataPacket{Sequence: 22}
time.Sleep(20 * time.Millisecond)
state = cache2.Add(pkt4)
state = cache2.Add(pkt4, 0)
require.Equal(t, MigrationDataCacheStateTimeout, state)
require.Len(t, cache2.Get(), 1)
require.Equal(t, uint32(22), cache2.Get()[0].Sequence)
}
func TestMigrationDataCache_MaxSize(t *testing.T) {
// the cache should not grow past the size budget even if the expiry is far in the future
cache := NewMigrationDataCache(10, time.Now().Add(time.Minute))
pktSize := 1000
seq := uint32(12)
state := MigrationDataCacheStateWaiting
for ; state == MigrationDataCacheStateWaiting; seq++ {
state = cache.Add(&livekit.DataPacket{Sequence: seq}, pktSize)
}
require.Equal(t, MigrationDataCacheStateTimeout, state)
require.LessOrEqual(t, cache.Size(), migrationDataCacheMaxSize+pktSize)
require.Len(t, cache.Get(), migrationDataCacheMaxSize/pktSize+1)
// once full, further packets are dropped, including the continuous one
require.Equal(t, MigrationDataCacheStateTimeout, cache.Add(&livekit.DataPacket{Sequence: 11}, pktSize))
}
+176 -56
View File
@@ -81,6 +81,10 @@ const (
cMaxPendingTracks = 20
cMaxPendingQueuedTracks = 3
// 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
cMaxJoiningUnsequencedReliableBytes = 100_000
PingIntervalSeconds = 5
PingTimeoutSeconds = 15
)
@@ -150,6 +154,9 @@ type reliableDataInfo struct {
joiningMessageLock sync.Mutex
joiningMessageFirstSeqs map[livekit.ParticipantID]uint32
joiningMessageLastWrittenSeqs map[livekit.ParticipantID]uint32
joiningUnsequencedMessages [][]byte
joiningUnsequencedBytes int
joiningUnsequencedDropped int
lastPubReliableSeq atomic.Uint32
stopReliableByMigrateOut atomic.Bool
canWriteReliable bool
@@ -244,6 +251,7 @@ type ParticipantImpl struct {
params ParticipantParams
participantListener atomic.Pointer[types.LocalParticipantListener]
telemetryListener atomic.Pointer[types.ParticipantTelemetryListener]
participantHelper atomic.Value // types.LocalParticipantHelper
id atomic.Value // types.ParticipantID
@@ -253,6 +261,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
@@ -391,17 +403,8 @@ func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) {
p.setupSignalling()
p.id.Store(params.SID)
p.dataChannelStats = NewBytesTrackStats(
p.params.Country,
BytesTrackIDForParticipantID(BytesTrackTypeData, p.ID()),
p.ID(),
params.Grants.GetParticipantKind(),
params.Grants.GetKindDetails(),
params.TelemetryListener,
params.Reporter,
)
p.reliableDataInfo.lastPubReliableSeq.Store(params.LastPubReliableSeq)
p.setListener(params.ParticipantListener)
p.setTelemetryListener(params.TelemetryListener)
p.participantHelper.Store(params.ParticipantHelper)
if !params.DisableSupervisor {
p.supervisor = supervisor.NewParticipantSupervisor(supervisor.ParticipantSupervisorParams{Logger: params.Logger})
@@ -410,6 +413,17 @@ func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) {
p.version.Store(params.InitialVersion)
p.timedVersion.Update(params.VersionGenerator.Next())
p.dataChannelStats = NewBytesTrackStats(
p.params.Country,
BytesTrackIDForParticipantID(BytesTrackTypeData, p.ID()),
p.ID(),
params.Grants.GetParticipantKind(),
params.Grants.GetKindDetails(),
p.GetTelemetryListener,
params.Reporter,
)
p.reliableDataInfo.lastPubReliableSeq.Store(params.LastPubReliableSeq)
p.migrateState.Store(types.MigrateStateInit)
p.state.Store(livekit.ParticipantInfo_JOINING)
@@ -493,6 +507,25 @@ func (p *ParticipantImpl) ClearParticipantListener() {
p.setListener(nil)
}
func (p *ParticipantImpl) setTelemetryListener(listener types.ParticipantTelemetryListener) {
if listener == nil {
p.telemetryListener.Store(nil)
return
}
p.telemetryListener.Store(&listener)
}
func (p *ParticipantImpl) GetTelemetryListener() types.ParticipantTelemetryListener {
if l := p.telemetryListener.Load(); l != nil {
return *l
}
return &types.NullParticipantTelemetryListener{}
}
func (p *ParticipantImpl) ClearTelemetryListener() {
p.setTelemetryListener(nil)
}
func (p *ParticipantImpl) GetCountry() string {
return p.params.Country
}
@@ -583,9 +616,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
@@ -979,14 +1013,6 @@ func (p *ParticipantImpl) TelemetryGuard() *telemetry.ReferenceGuard {
return p.telemetryGuard
}
func (p *ParticipantImpl) GetTelemetryListener() types.ParticipantTelemetryListener {
if p.params.TelemetryListener == nil {
return &types.NullParticipantTelemetryListener{}
}
return p.params.TelemetryListener
}
func (p *ParticipantImpl) AddOnClose(key string, callback func(types.LocalParticipant)) {
if p.isClosed.Load() {
if callback != nil {
@@ -1013,7 +1039,7 @@ func (p *ParticipantImpl) OnClaimsChanged(callback func(types.LocalParticipant))
func (p *ParticipantImpl) HandleSignalSourceClose() {
p.TransportManager.SetSignalSourceValid(false)
if !p.HasConnected() {
if !p.HasICEConnected() {
_ = p.Close(false, types.ParticipantCloseReasonSignalSourceClose, false)
}
}
@@ -1402,7 +1428,7 @@ func (p *ParticipantImpl) SetMigrateInfo(
// for migrating in tracks, there is no AddTrack, so record a synthetic publish request
for _, t := range mediaTracks {
p.params.TelemetryListener.OnTrackPublishRequested(p.ID(), p.Identity(), t.GetTrack(), false)
p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), t.GetTrack(), false)
}
for _, t := range dataTracks {
@@ -1418,7 +1444,7 @@ func (p *ParticipantImpl) SetMigrateInfo(
p.ID(),
p.Kind(),
p.KindDetails(),
p.params.TelemetryListener,
p.GetTelemetryListener,
p.params.Reporter,
),
},
@@ -1449,7 +1475,7 @@ func (p *ParticipantImpl) IsMigration() bool {
}
func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReason) {
if p.HasConnected() {
if p.HasICEConnected() {
return
}
@@ -1461,7 +1487,8 @@ func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReaso
}
func (p *ParticipantImpl) IsConnectionCanceled(closeReason types.ParticipantCloseReason) bool {
return closeReason == types.ParticipantCloseReasonClientRequestLeave ||
return closeReason == types.ParticipantCloseReasonJoinFailed ||
closeReason == types.ParticipantCloseReasonClientRequestLeave ||
closeReason == types.ParticipantCloseReasonDuplicateIdentity ||
closeReason == types.ParticipantCloseReasonRoomClosed ||
closeReason == types.ParticipantCloseReasonMigrationRequested ||
@@ -2049,8 +2076,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,
})
}
@@ -2169,7 +2197,6 @@ func (p *ParticipantImpl) setupSubscriptionManager() {
DataTrackResolver: func(lp types.LocalParticipant, ti livekit.TrackID) types.DataResolverResult {
return p.helper().ResolveDataTrack(lp, ti)
},
TelemetryListener: p.params.TelemetryListener,
OnTrackSubscribed: p.onTrackSubscribed,
OnTrackUnsubscribed: p.onTrackUnsubscribed,
OnSubscriptionError: p.onSubscriptionError,
@@ -2177,6 +2204,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() {
@@ -2366,6 +2396,7 @@ func (p *ParticipantImpl) onMediaTrack(rtcTrack *webrtc.TrackRemote, rtpReceiver
"ssrc", track.SSRC(),
"rtxSsrc", track.RtxSSRC(),
"mime", mime.NormalizeMimeType(codec.MimeType),
"isNewTrack", isNewTrack,
"isReceiverAdded", isReceiverAdded,
"sdpRids", logger.StringSlice(sdpRids[:]),
)
@@ -2440,7 +2471,7 @@ func (p *ParticipantImpl) onReceivedDataMessage(kind livekit.DataPacket_Kind, da
}
if migrationCache := p.reliableDataInfo.migrateInPubDataCache.Load(); migrationCache != nil {
switch migrationCache.Add(dp) {
switch migrationCache.Add(dp, len(data)) {
case MigrationDataCacheStateWaiting:
// waiting for the reliable sequence to continue from last node
return
@@ -2459,6 +2490,8 @@ func (p *ParticipantImpl) onReceivedDataMessage(kind livekit.DataPacket_Kind, da
"migration data cache timed out, handling cached messages", nil,
"cachedFirstSeq", cachedMsgs[0].Sequence,
"cachedLastSeq", cachedMsgs[len(cachedMsgs)-1].Sequence,
"cachedNum", len(cachedMsgs),
"cachedSize", migrationCache.Size(),
"lastPubReliableSeq", p.params.LastPubReliableSeq,
)
}
@@ -2854,7 +2887,7 @@ func (p *ParticipantImpl) onSubscribedMaxQualityChange(
break
}
}
p.params.TelemetryListener.OnTrackMaxSubscribedVideoQuality(
p.GetTelemetryListener().OnTrackMaxSubscribedVideoQuality(
p.ID(),
ti,
maxSubscribedQuality.CodecMime,
@@ -3157,7 +3190,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit
}
p.pendingTracksLock.Unlock()
p.params.TelemetryListener.OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true)
p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true)
return nil
}
@@ -3182,7 +3215,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit
}
p.pendingTracksLock.Unlock()
p.params.TelemetryListener.OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true)
p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true)
return ti
}
@@ -3199,6 +3232,10 @@ func (p *ParticipantImpl) GetPendingTrack(trackID livekit.TrackID) *livekit.Trac
return nil
}
func (p *ParticipantImpl) HasICEConnected() bool {
return p.TransportManager.HasSubscriberICEEverConnected() || p.TransportManager.HasPublisherICEEverConnected()
}
func (p *ParticipantImpl) HasConnected() bool {
return p.TransportManager.HasSubscriberEverConnected() || p.TransportManager.HasPublisherEverConnected()
}
@@ -3244,9 +3281,9 @@ func (p *ParticipantImpl) setTrackMuted(mute *livekit.MuteTrackRequest, fromAdmi
if trackInfo != nil && changed {
if mute.Muted {
p.params.TelemetryListener.OnTrackMuted(p.ID(), trackInfo)
p.GetTelemetryListener().OnTrackMuted(p.ID(), trackInfo)
} else {
p.params.TelemetryListener.OnTrackUnmuted(p.ID(), trackInfo)
p.GetTelemetryListener().OnTrackUnmuted(p.ID(), trackInfo)
}
}
@@ -3267,7 +3304,6 @@ func (p *ParticipantImpl) mediaTrackReceived(
rtpReceiver *webrtc.RTPReceiver,
) (*MediaTrack, bool, bool, buffer.VideoLayersRid) {
p.pendingTracksLock.Lock()
newTrack := false
mid := p.TransportManager.GetPublisherMid(rtpReceiver)
p.pubLogger.Debugw(
@@ -3291,10 +3327,13 @@ func (p *ParticipantImpl) mediaTrackReceived(
}
// use existing media track to handle simulcast
var createdAt time.Time
var isMigrated bool
var ridsFromSdp buffer.VideoLayersRid
var pubTime time.Duration
var (
createdAt time.Time
isNewTrack bool
isMigrated bool
ridsFromSdp buffer.VideoLayersRid
pubTime time.Duration
)
mt, ok := p.getPublishedTrackBySdpCid(track.ID()).(*MediaTrack)
if !ok {
var (
@@ -3324,7 +3363,14 @@ func (p *ParticipantImpl) mediaTrackReceived(
}
}
if codecFound != len(ti.Codecs) {
p.pubLogger.Warnw("migrated track codec mismatched", nil, "track", logger.Proto(ti), "webrtcCodec", parameters)
p.pubLogger.Warnw(
"migrated track codec mismatched", nil,
"trackID", ti.Sid,
"track", logger.Proto(ti),
"webrtcCodec", parameters,
"codecFound", codecFound,
"codecCount", len(ti.Codecs),
)
p.pendingTracksLock.Unlock()
p.IssueFullReconnect(types.ParticipantCloseReasonMigrateCodecMismatch)
return nil, false, false, ridsFromSdp
@@ -3348,7 +3394,7 @@ func (p *ParticipantImpl) mediaTrackReceived(
}
mt = p.addMediaTrack(signalCid, ti)
newTrack = true
isNewTrack = true
}
// a track might have been set up in migrate-in path and won't show up as a new track here,
@@ -3360,12 +3406,12 @@ func (p *ParticipantImpl) mediaTrackReceived(
}
}
}
if !newTrack {
newTrack = !mt.Published()
if !isNewTrack {
isNewTrack = !mt.Published()
}
mt.SetPublished(true)
if newTrack {
if isNewTrack {
// if the addTrackRequest is sent before publisher peer connection is established, then it means the client tries to publish
// before fully connected, in this case we only record the time when publisher peer connection is established since
// we want this metric to represent the time cost by publishing.
@@ -3379,7 +3425,7 @@ func (p *ParticipantImpl) mediaTrackReceived(
_, isReceiverAdded := mt.AddReceiver(rtpReceiver, track, mid)
if newTrack {
if isNewTrack {
go func() {
// TODO: remove this after we know where the high delay is coming from
if pubTime > 3*time.Second {
@@ -3410,11 +3456,12 @@ func (p *ParticipantImpl) mediaTrackReceived(
p.GetClientInfo().GetSdk(),
p.Kind(),
)
p.handleTrackPublished(mt, isMigrated, false)
}()
}
return mt, newTrack, isReceiverAdded, ridsFromSdp
return mt, isNewTrack, isReceiverAdded, ridsFromSdp
}
func (p *ParticipantImpl) addMigratedTrack(cid string, ti *livekit.TrackInfo) *MediaTrack {
@@ -3429,6 +3476,31 @@ func (p *ParticipantImpl) addMigratedTrack(cid string, ti *livekit.TrackInfo) *M
return nil
}
// check if the migrated track has correct codec
if len(ti.Codecs) > 0 {
parameters := rtpReceiver.GetParameters()
var codecFound int
for _, c := range ti.Codecs {
for _, nc := range parameters.Codecs {
if mime.IsMimeTypeStringEqual(nc.MimeType, c.MimeType) {
codecFound++
break
}
}
}
if codecFound != len(ti.Codecs) {
p.pubLogger.Warnw(
"migrated track codec mismatched", nil,
"trackID", ti.Sid,
"track", logger.Proto(ti),
"webrtcCodec", parameters,
"codecFound", codecFound,
"codecCount", len(ti.Codecs),
)
return nil
}
}
mt := p.addMediaTrack(cid, ti)
mt.SetMigrated(true)
@@ -3485,7 +3557,7 @@ func (p *ParticipantImpl) addMediaTrack(signalCid string, ti *livekit.TrackInfo)
ReceiverConfig: p.params.Config.Receiver,
AudioConfig: p.params.AudioConfig,
VideoConfig: p.params.VideoConfig,
TelemetryListener: p.params.TelemetryListener,
TelemetryListener: p.GetTelemetryListener,
Logger: LoggerWithTrack(p.pubLogger, livekit.TrackID(ti.Sid), false),
Reporter: p.params.Reporter.WithTrack(ti.Sid),
SubscriberConfig: p.params.Config.Subscriber,
@@ -3501,11 +3573,10 @@ func (p *ParticipantImpl) addMediaTrack(signalCid string, ti *livekit.TrackInfo)
EnableRTPStreamRestartDetection: p.params.EnableRTPStreamRestartDetection,
UpdateTrackInfoByVideoSizeChange: p.params.UseOneShotSignallingMode,
ForceBackupCodecPolicySimulcast: p.params.ForceBackupCodecPolicySimulcast,
OnSubscribedMaxQualityChange: p.onSubscribedMaxQualityChange,
OnSubscribedAudioCodecChange: p.onSubscribedAudioCodecChange,
}, ti)
mt.OnSubscribedMaxQualityChange(p.onSubscribedMaxQualityChange)
mt.OnSubscribedAudioCodecChange(p.onSubscribedAudioCodecChange)
// add to published and clean up pending
if p.supervisor != nil {
p.supervisor.SetPublishedTrack(livekit.TrackID(ti.Sid), mt)
@@ -3537,7 +3608,7 @@ func (p *ParticipantImpl) addMediaTrack(signalCid string, ti *livekit.TrackInfo)
p.supervisor.ClearPublishedTrack(trackID, mt)
}
p.params.TelemetryListener.OnTrackUnpublished(
p.GetTelemetryListener().OnTrackUnpublished(
p.ID(),
p.Identity(),
mt.ToProto(),
@@ -3573,7 +3644,7 @@ func (p *ParticipantImpl) handleTrackPublished(track types.MediaTrack, isMigrate
if !isSynthetic {
// send webhook after callbacks are complete, persistence and state handling happens
// in `onTrackPublished` cb
p.params.TelemetryListener.OnTrackPublished(
p.GetTelemetryListener().OnTrackPublished(
p.ID(),
p.Identity(),
track.ToProto(),
@@ -3952,7 +4023,37 @@ func (p *ParticipantImpl) SupportsTransceiverReuse(mt types.MediaTrack) bool {
}
func (p *ParticipantImpl) SendDataMessage(kind livekit.DataPacket_Kind, data []byte, sender livekit.ParticipantID, seq uint32) error {
if sender == "" || kind != livekit.DataPacket_RELIABLE || seq == 0 {
if kind != livekit.DataPacket_RELIABLE {
if p.State() != livekit.ParticipantInfo_ACTIVE {
return ErrDataChannelUnavailable
}
return p.TransportManager.SendDataMessage(kind, data)
}
if sender == "" || seq == 0 {
// Unsequenced reliable data, i. e. not published by a participant, room service
// SendData for example. Such a message cannot be recovered by
// replayJoiningReliableMessages as the data message cache is keyed on
// sender/sequence number, so hold on to the message itself here till the
// reliable data channel is writable.
p.reliableDataInfo.joiningMessageLock.Lock()
if !p.reliableDataInfo.canWriteReliable {
if p.reliableDataInfo.joiningUnsequencedBytes+len(data) > cMaxJoiningUnsequencedReliableBytes {
p.reliableDataInfo.joiningUnsequencedDropped++
p.reliableDataInfo.joiningMessageLock.Unlock()
return ErrDataChannelUnavailable
}
p.reliableDataInfo.joiningUnsequencedMessages = append(
p.reliableDataInfo.joiningUnsequencedMessages,
slices.Clone(data),
)
p.reliableDataInfo.joiningUnsequencedBytes += len(data)
p.reliableDataInfo.joiningMessageLock.Unlock()
return nil
}
p.reliableDataInfo.joiningMessageLock.Unlock()
if p.State() != livekit.ParticipantInfo_ACTIVE {
return ErrDataChannelUnavailable
}
@@ -4067,6 +4168,20 @@ func (p *ParticipantImpl) replayJoiningReliableMessages() {
p.TransportManager.SendDataMessage(livekit.DataPacket_RELIABLE, msgCache.Data)
}
for _, msg := range p.reliableDataInfo.joiningUnsequencedMessages {
p.TransportManager.SendDataMessage(livekit.DataPacket_RELIABLE, msg)
}
if p.reliableDataInfo.joiningUnsequencedDropped != 0 {
p.params.Logger.Warnw(
"dropped unsequenced reliable data messages while joining", nil,
"numDropped", p.reliableDataInfo.joiningUnsequencedDropped,
"numReplayed", len(p.reliableDataInfo.joiningUnsequencedMessages),
)
}
p.reliableDataInfo.joiningUnsequencedMessages = nil
p.reliableDataInfo.joiningUnsequencedBytes = 0
p.reliableDataInfo.joiningUnsequencedDropped = 0
p.reliableDataInfo.joiningMessageFirstSeqs = make(map[livekit.ParticipantID]uint32)
p.reliableDataInfo.canWriteReliable = true
p.reliableDataInfo.joiningMessageLock.Unlock()
@@ -4207,7 +4322,7 @@ func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) {
track.(types.LocalMediaTrack).ClearSubscriberNodes()
trackInfo := track.ToProto()
p.params.TelemetryListener.OnTrackUnpublished(
p.GetTelemetryListener().OnTrackUnpublished(
p.ID(),
p.Identity(),
trackInfo,
@@ -4216,6 +4331,9 @@ func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) {
)
}
p.params.Reporter.ReportEndTime(time.Now())
p.SubscriptionManager.ClearAllSubscriptions()
// fire onClose callback for original room
p.lock.Lock()
onClose := p.onClose
@@ -4231,13 +4349,15 @@ func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) {
p.telemetryGuard = &telemetry.ReferenceGuard{}
p.lock.Unlock()
p.params.Reporter.ReportEndTime(time.Now())
p.params.LoggerResolver.Reset()
p.params.ReporterResolver.Reset()
p.setListener(params.Listener)
p.setTelemetryListener(params.TelemetryListener)
p.participantHelper.Store(params.Helper)
p.SubscriptionManager.ClearAllSubscriptions()
p.id.Store(params.ParticipantID)
grants := p.grants.Load().Clone()
grants.Video.Room = string(params.RoomName)
p.grants.Store(grants)
+2 -2
View File
@@ -15,8 +15,8 @@
package rtc
import (
"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"
@@ -121,7 +121,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData
p.ID(),
p.Kind(),
p.KindDetails(),
p.params.TelemetryListener,
p.GetTelemetryListener,
p.params.Reporter,
),
},
+200
View File
@@ -783,6 +783,7 @@ type participantOpts struct {
publisher bool
clientConf *livekit.ClientConfiguration
clientInfo *livekit.ClientInfo
migration bool
}
func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *participantOpts) *ParticipantImpl {
@@ -837,6 +838,7 @@ func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *p
VersionGenerator: utils.NewDefaultTimedVersionGenerator(),
ParticipantListener: &typesfakes.FakeLocalParticipantListener{},
ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{},
Migration: opts.migration,
})
p.isPublisher.Store(opts.publisher)
p.updateState(livekit.ParticipantInfo_ACTIVE)
@@ -847,3 +849,201 @@ func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *p
func newParticipantForTest(identity livekit.ParticipantIdentity) *ParticipantImpl {
return newParticipantForTestWithOpts(identity, nil)
}
func TestUnsequencedReliableDataBufferedWhileJoining(t *testing.T) {
// unsequenced reliable data, i. e. room service SendData, arriving before the
// reliable data channel is writable should be held and replayed, not dropped
t.Run("buffers and replays", func(t *testing.T) {
p := newParticipantForTest("test")
require.False(t, p.reliableDataInfo.canWriteReliable)
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("one"), "", 0))
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("two"), "", 0))
require.Equal(t, [][]byte{[]byte("one"), []byte("two")}, p.reliableDataInfo.joiningUnsequencedMessages)
require.Equal(t, 6, p.reliableDataInfo.joiningUnsequencedBytes)
p.replayJoiningReliableMessages()
require.True(t, p.reliableDataInfo.canWriteReliable)
require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages)
require.Zero(t, p.reliableDataInfo.joiningUnsequencedBytes)
})
t.Run("does not buffer once writable", func(t *testing.T) {
p := newParticipantForTest("test")
p.replayJoiningReliableMessages()
// no data channel in test, so the write through fails rather than getting buffered
require.Error(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("one"), "", 0))
require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages)
})
t.Run("bounded buffer", func(t *testing.T) {
p := newParticipantForTest("test")
data := make([]byte, cMaxJoiningUnsequencedReliableBytes)
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, data, "", 0))
require.Error(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("overflow"), "", 0))
require.Len(t, p.reliableDataInfo.joiningUnsequencedMessages, 1)
require.Equal(t, 1, p.reliableDataInfo.joiningUnsequencedDropped)
})
t.Run("lossy is not buffered", func(t *testing.T) {
p := newParticipantForTest("test")
require.Error(t, p.SendDataMessage(livekit.DataPacket_LOSSY, []byte("one"), "", 0))
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},
))
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{},
))
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{},
))
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},
))
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{},
))
require.Zero(t, sink.WriteMessageCallCount())
require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"}))
require.Equal(t, 1, sink.WriteMessageCallCount())
})
}
+55 -3
View File
@@ -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()
@@ -177,11 +179,23 @@ func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit
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() {
return p.sendDisconnectUpdatesForReconnect()
@@ -190,6 +204,44 @@ func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit
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
+9 -8
View File
@@ -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
@@ -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
}
+22 -13
View File
@@ -377,8 +377,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()
@@ -392,6 +394,7 @@ 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))
})
@@ -399,19 +402,24 @@ func TestRoomClosure(t *testing.T) {
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
@@ -420,6 +428,7 @@ func TestRoomClosure(t *testing.T) {
time.Sleep(1010 * time.Millisecond)
rm.CloseIfEmpty()
require.True(t, isClosed)
require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason)
})
}
@@ -461,7 +470,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())
@@ -473,7 +482,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)
@@ -488,7 +497,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
@@ -527,7 +536,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)
@@ -621,7 +630,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()
@@ -667,7 +676,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()
@@ -713,7 +722,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)
@@ -743,7 +752,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)
@@ -775,7 +784,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())
@@ -791,7 +800,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...")
+9 -5
View File
@@ -60,7 +60,7 @@ type BytesTrackStats struct {
sendMessages, recvMessages atomic.Uint32
totalSendBytes, totalRecvBytes atomic.Uint64
totalSendMessages, totalRecvMessages atomic.Uint32
telemetryListener types.ParticipantTelemetryListener
telemetryListener func() types.ParticipantTelemetryListener
reporter roomobs.TrackReporter
done core.Fuse
}
@@ -71,7 +71,7 @@ func NewBytesTrackStats(
pID livekit.ParticipantID,
kind livekit.ParticipantInfo_Kind,
kindDetails []livekit.ParticipantInfo_KindDetail,
telemetryListener types.ParticipantTelemetryListener,
telemetryListener func() types.ParticipantTelemetryListener,
participantReporter roomobs.ParticipantSessionReporter,
) *BytesTrackStats {
s := &BytesTrackStats{
@@ -134,7 +134,7 @@ func (s *BytesTrackStats) Stop() {
func (s *BytesTrackStats) report() {
if recv := s.recv.Swap(0); recv > 0 {
packets := s.recvMessages.Swap(0)
s.telemetryListener.OnTrackStats(
s.telemetryListener().OnTrackStats(
telemetry.StatsKeyForData(s.country, livekit.StreamType_UPSTREAM, s.pID, s.trackID),
&livekit.AnalyticsStat{
Streams: []*livekit.AnalyticsStream{
@@ -149,7 +149,7 @@ func (s *BytesTrackStats) report() {
if send := s.send.Swap(0); send > 0 {
packets := s.sendMessages.Swap(0)
s.telemetryListener.OnTrackStats(
s.telemetryListener().OnTrackStats(
telemetry.StatsKeyForData(s.country, livekit.StreamType_DOWNSTREAM, s.pID, s.trackID),
&livekit.AnalyticsStat{
Streams: []*livekit.AnalyticsStream{
@@ -217,7 +217,7 @@ func NewBytesSignalStats(
trackResolver: trackReporterResolver,
}
b.BytesTrackStats = BytesTrackStats{
telemetryListener: b,
telemetryListener: b.telemetryListener,
reporter: trackReporter,
}
return b
@@ -295,6 +295,10 @@ func (s *BytesSignalStats) worker() {
close(s.stopped)
}
func (s *BytesSignalStats) telemetryListener() types.ParticipantTelemetryListener {
return s
}
func (s *BytesSignalStats) OnTrackStats(key telemetry.StatsKey, stat *livekit.AnalyticsStat) {
stat.RoomId, stat.RoomName = s.ri.Sid, s.ri.Name
s.telemetry.TrackStats(livekit.RoomID(s.ri.Sid), livekit.RoomName(s.ri.Name), key, stat)
+7
View File
@@ -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
}
+39 -13
View File
@@ -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
+106
View File
@@ -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()
@@ -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())
})
}
@@ -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
}
+31 -15
View File
@@ -57,7 +57,6 @@ type SubscriptionManagerParams struct {
OnTrackSubscribed func(subTrack types.SubscribedTrack)
OnTrackUnsubscribed func(subTrack types.SubscribedTrack)
OnSubscriptionError func(trackID livekit.TrackID, fatal bool, err error)
TelemetryListener types.ParticipantTelemetryListener
SubscriptionLimitVideo, SubscriptionLimitAudio int32
@@ -308,6 +307,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()
@@ -503,7 +515,7 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) {
numAttempts := s.getNumAttempts()
if numAttempts == 0 {
m.params.TelemetryListener.OnTrackSubscribeRequested(
m.params.Participant.GetTelemetryListener().OnTrackSubscribeRequested(
s.subscriberID,
&livekit.TrackInfo{
Sid: string(s.trackID),
@@ -523,14 +535,14 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) {
// - ErrSubscriptionLimitExceeded: the participant have reached the limit of subscriptions, wait for the other subscription to be unsubscribed
// We'll still log an event to reflect this in telemetry since it's been too long
if s.durationSinceStart() > subscriptionTimeout {
s.maybeRecordError(m.params.TelemetryListener, err, true)
s.maybeRecordError(m.params.Participant.GetTelemetryListener(), err, true)
}
case ErrTrackNotFound:
// source track was never published or closed
// if after timeout we'd unsubscribe from it.
// this is the *only* case we'd change desired state
if s.durationSinceStart() > notFoundTimeout {
s.maybeRecordError(m.params.TelemetryListener, err, true)
s.maybeRecordError(m.params.Participant.GetTelemetryListener(), err, true)
s.logger.Infow("unsubscribing from track after notFoundTimeout", "error", err)
s.setDesired(false)
m.queueReconcile(s.trackID)
@@ -543,7 +555,7 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) {
"failed to subscribe, triggering error handler", err,
"attempt", s.getNumAttempts(),
)
s.maybeRecordError(m.params.TelemetryListener, err, false)
s.maybeRecordError(m.params.Participant.GetTelemetryListener(), err, false)
m.params.OnSubscriptionError(s.trackID, true, err)
} else {
s.logger.Debugw(
@@ -582,7 +594,7 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) {
wait := min(time.Since(activeAt), s.durationSinceStart())
if wait > subscriptionTimeout {
s.logger.Warnw("track not bound after timeout", nil)
s.maybeRecordError(m.params.TelemetryListener, ErrTrackNotBound, false)
s.maybeRecordError(m.params.Participant.GetTelemetryListener(), ErrTrackNotBound, false)
m.params.OnSubscriptionError(s.trackID, true, ErrTrackNotBound)
}
}
@@ -600,7 +612,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)
}
}
@@ -631,7 +643,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
@@ -669,12 +681,16 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri
}
m.lock.Lock()
if s.needsCleanup() {
cleanedUp := 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
@@ -873,13 +889,13 @@ func (m *SubscriptionManager) addSubscriber(sub *mediaTrackSubscription, track t
subTrack.AddOnBind(func(err error) {
if err != nil {
sub.logger.Infow("failed to bind track", "err", err)
sub.maybeRecordError(m.params.TelemetryListener, err, true)
sub.maybeRecordError(m.params.Participant.GetTelemetryListener(), err, true)
m.UnsubscribeFromTrack(trackID)
m.params.OnSubscriptionError(trackID, false, err)
return
}
sub.setBound()
sub.maybeRecordSuccess(m.params.TelemetryListener)
sub.maybeRecordSuccess(m.params.Participant.GetTelemetryListener())
})
sub.setSubscribedTrack(subTrack)
@@ -1003,7 +1019,7 @@ func (m *SubscriptionManager) handleSubscribedTrackClose(s *mediaTrackSubscripti
// * the participant isn't closing
// * it's not a migration
if wasBound {
m.params.TelemetryListener.OnTrackUnsubscribed(
m.params.Participant.GetTelemetryListener().OnTrackUnsubscribed(
s.subscriberID,
&livekit.TrackInfo{Sid: string(s.trackID), Type: subTrack.MediaTrack().Kind()},
!isExpectedToResume,
@@ -1013,7 +1029,7 @@ func (m *SubscriptionManager) handleSubscribedTrackClose(s *mediaTrackSubscripti
if dt != nil {
stats := dt.GetTrackStats()
if stats != nil {
m.params.TelemetryListener.OnTrackSubscribeRTPStats(
m.params.Participant.GetTelemetryListener().OnTrackSubscribeRTPStats(
s.subscriberID,
s.trackID,
dt.Mime(),
@@ -1223,7 +1239,7 @@ func (m *SubscriptionManager) unmarkSubscribedTo(publisherID livekit.Participant
}
m.lock.Unlock()
if changedCB != nil && lastSubscription {
go changedCB(publisherID, false)
changedCB(publisherID, false)
}
}
+42 -7
View File
@@ -82,7 +82,7 @@ func TestSubscribe(t *testing.T) {
require.Equal(t, "pubID", string(sm.GetSubscribedParticipants()[0]))
// ensure telemetry events are sent
tl := sm.params.TelemetryListener.(*typesfakes.FakeParticipantTelemetryListener)
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
// ensure bound
@@ -113,7 +113,9 @@ func TestSubscribe(t *testing.T) {
require.Eventually(t, func() bool {
return numParticipantSubscribed.Load() == 2
}, subSettleTimeout, subCheckInterval, "participant subscribe status was not updated twice")
require.Equal(t, int32(1), numParticipantUnsubscribed.Load())
require.Eventually(t, func() bool {
return numParticipantUnsubscribed.Load() == 1
}, subSettleTimeout, subCheckInterval, "participant unsubscribe status was not updated")
})
t.Run("no track permission", func(t *testing.T) {
@@ -141,7 +143,7 @@ func TestSubscribe(t *testing.T) {
require.Len(t, sm.GetSubscribedTracks(), 0)
// trackSubscribed telemetry not sent
tl := sm.params.TelemetryListener.(*typesfakes.FakeParticipantTelemetryListener)
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
require.Equal(t, 0, tl.OnTrackSubscribedCallCount())
@@ -248,9 +250,12 @@ func TestUnsubscribe(t *testing.T) {
// no traces should be left
require.Len(t, sm.GetSubscribedTracks(), 0)
require.False(t, res.TrackChangedNotifier.HasObservers())
// the observer is dropped on a goroutine of its own
require.Eventually(t, func() bool {
return !res.TrackChangedNotifier.HasObservers()
}, subSettleTimeout, subCheckInterval, "observer was not removed")
tl := sm.params.TelemetryListener.(*typesfakes.FakeParticipantTelemetryListener)
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackUnsubscribedCallCount())
}
@@ -390,7 +395,7 @@ func TestSubscriptionLimits(t *testing.T) {
require.Equal(t, "pubID", string(sm.GetSubscribedParticipants()[0]))
// ensure telemetry events are sent
tl := sm.params.TelemetryListener.(*typesfakes.FakeParticipantTelemetryListener)
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
// ensure bound
@@ -521,6 +526,33 @@ func TestSubscribeDataTrack(t *testing.T) {
}, subSettleTimeout, subCheckInterval, "should be resubscribed")
require.Equal(t, 2, resolver.dataTrack.AddSubscriberCallCount())
})
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 {
@@ -538,6 +570,10 @@ func newTestSubscriptionManagerWithParams(params testSubscriptionParams) *Subscr
p.IDReturns("subID")
p.IdentityReturns("sub")
p.KindReturns(livekit.ParticipantInfo_STANDARD)
tl := &typesfakes.FakeParticipantTelemetryListener{}
p.GetTelemetryListenerReturns(tl)
return NewSubscriptionManager(SubscriptionManagerParams{
Participant: p,
Logger: logger.GetLogger(),
@@ -547,7 +583,6 @@ func newTestSubscriptionManagerWithParams(params testSubscriptionParams) *Subscr
TrackResolver: func(sub types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
return types.MediaResolverResult{}
},
TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{},
SubscriptionLimitAudio: params.SubscriptionLimitAudio,
SubscriptionLimitVideo: params.SubscriptionLimitVideo,
})
+39 -29
View File
@@ -223,14 +223,14 @@ type PCTransport struct {
dataTrackDC *datachannel.DataChannelWriter[*webrtc.DataChannel]
unlabeledDataChannels []*datachannel.DataChannelWriter[*webrtc.DataChannel]
iceStartedAt time.Time
iceConnectedAt time.Time
firstConnectedAt time.Time
connectedAt time.Time
tcpICETimer *time.Timer
connectAfterICETimer *time.Timer // timer to wait for pc to connect after ice connected
resetShortConnOnICERestart atomic.Bool
signalingRTT atomic.Uint32 // milliseconds
iceFirstStartedAt time.Time
iceFirstConnectedAt time.Time
peerConnectionFirstConnectedAt time.Time
peerConnectionLastconnectedAt time.Time
tcpICETimer *time.Timer
connectAfterICETimer *time.Timer // timer to wait for pc to connect after ice connected
resetShortConnOnICERestart atomic.Bool
signalingRTT atomic.Uint32 // milliseconds
hasFullyEstablishedRecorded bool
@@ -384,6 +384,7 @@ func newPeerConnection(
}
if params.EnableWarp {
params.Logger.Debugw("enable warp")
se.EnableSped(true)
se.EnableSctpSnap(true)
}
@@ -530,6 +531,8 @@ func newPeerConnection(
params.Logger.Debugw("rtx pair found from extension", "repair", repair, "base", base, "rsid", rsid)
params.Config.BufferFactory.SetRTXPair(repair, base, rsid)
},
params.Config.BufferFactory,
params.SimTracks,
params.Logger,
)
// put rtx interceptor behind unhandle simulcast interceptor so it can get the correct mid & rid
@@ -670,8 +673,8 @@ func (t *PCTransport) SetSignalingRTT(rtt uint32) {
func (t *PCTransport) setICEStartedAt(at time.Time) {
t.lock.Lock()
if t.iceStartedAt.IsZero() {
t.iceStartedAt = at
if t.iceFirstStartedAt.IsZero() {
t.iceFirstStartedAt = at
// checklist of ice agent will be cleared on ice failed, get stats before that
t.mayFailedICEStatsTimer = time.AfterFunc(iceFailedTimeoutTotal-time.Second, t.logMayFailedICEStats)
@@ -702,15 +705,15 @@ func (t *PCTransport) setICEStartedAt(at time.Time) {
func (t *PCTransport) setICEConnectedAt(at time.Time) {
t.lock.Lock()
if t.iceConnectedAt.IsZero() {
if t.iceFirstConnectedAt.IsZero() {
//
// Record initial connection time.
// This prevents reset of connected at time if ICE goes `Connected` -> `Disconnected` -> `Connected`.
// This prevents reset of iceFirstConnectedAt if ICE goes `Connected` -> `Disconnected` -> `Connected`.
//
t.iceConnectedAt = at
t.iceFirstConnectedAt = at
// set failure timer for dtls handshake
iceDuration := at.Sub(t.iceStartedAt)
iceDuration := at.Sub(t.iceFirstStartedAt)
connTimeoutAfterICE := min(max(minConnectTimeoutAfterICE, 3*iceDuration), maxConnectTimeoutAfterICE)
t.params.Logger.Debugw("setting connection timer after ICE connected", "timeout", connTimeoutAfterICE, "iceDuration", iceDuration)
t.connectAfterICETimer = time.AfterFunc(connTimeoutAfterICE, func() {
@@ -777,9 +780,9 @@ func (t *PCTransport) logMayFailedICEStats() {
func (t *PCTransport) resetShortConn() {
t.params.Logger.Infow("resetting short connection on ICE restart")
t.lock.Lock()
t.iceStartedAt = time.Time{}
t.iceConnectedAt = time.Time{}
t.connectedAt = time.Time{}
t.iceFirstStartedAt = time.Time{}
t.iceFirstConnectedAt = time.Time{}
t.peerConnectionLastconnectedAt = time.Time{}
if t.connectAfterICETimer != nil {
t.connectAfterICETimer.Stop()
t.connectAfterICETimer = nil
@@ -795,23 +798,23 @@ func (t *PCTransport) IsShortConnection(at time.Time) (bool, time.Duration) {
t.lock.RLock()
defer t.lock.RUnlock()
if t.iceConnectedAt.IsZero() {
if t.iceFirstConnectedAt.IsZero() {
return false, 0
}
duration := at.Sub(t.iceConnectedAt)
duration := at.Sub(t.iceFirstConnectedAt)
return duration < shortConnectionThreshold, duration
}
func (t *PCTransport) setConnectedAt(at time.Time) bool {
func (t *PCTransport) setPeerConnectionConnectedAt(at time.Time) bool {
t.lock.Lock()
t.connectedAt = at
if !t.firstConnectedAt.IsZero() {
t.peerConnectionLastconnectedAt = at
if !t.peerConnectionFirstConnectedAt.IsZero() {
t.lock.Unlock()
return false
}
t.firstConnectedAt = at
t.peerConnectionFirstConnectedAt = at
prometheus.RecordServiceOperationSuccess("peer_connection")
prometheus.RecordPeerConnectionState(t.params.Transport, "connected")
t.lock.Unlock()
@@ -865,7 +868,7 @@ func (t *PCTransport) onPeerConnectionStateChange(state webrtc.PeerConnectionSta
switch state {
case webrtc.PeerConnectionStateConnected:
t.clearConnTimer()
isInitialConnection := t.setConnectedAt(time.Now())
isInitialConnection := t.setPeerConnectionConnectedAt(time.Now())
if isInitialConnection {
t.params.Handler.OnInitialConnected()
@@ -993,7 +996,7 @@ func (t *PCTransport) isFullyEstablished() bool {
dataChannelReady := t.params.UseOneShotSignallingMode || t.firstOfferNoDataChannel || (t.reliableDCOpened && t.lossyDCOpened)
return dataChannelReady && !t.connectedAt.IsZero()
return dataChannelReady && !t.peerConnectionLastconnectedAt.IsZero()
}
func (t *PCTransport) SetPreferTCP(preferTCP bool) {
@@ -1440,18 +1443,25 @@ func (t *PCTransport) IsEstablished() bool {
return t.pc.ConnectionState() != webrtc.PeerConnectionStateNew
}
func (t *PCTransport) HasEverConnected() bool {
func (t *PCTransport) ICEHasEverConnected() bool {
t.lock.RLock()
defer t.lock.RUnlock()
return !t.firstConnectedAt.IsZero()
return !t.iceFirstConnectedAt.IsZero()
}
func (t *PCTransport) FirstConnectedAt() time.Time {
func (t *PCTransport) PeerConnectionHasEverConnected() bool {
t.lock.RLock()
defer t.lock.RUnlock()
return t.firstConnectedAt
return !t.peerConnectionFirstConnectedAt.IsZero()
}
func (t *PCTransport) PeerConnectionFirstConnectedAt() time.Time {
t.lock.RLock()
defer t.lock.RUnlock()
return t.peerConnectionFirstConnectedAt
}
func (t *PCTransport) GetICEConnectionInfo() *types.ICEConnectionInfo {
+18 -6
View File
@@ -232,12 +232,16 @@ func (t *TransportManager) SubscriberClose() {
t.subscriber.Close()
}
func (t *TransportManager) HasPublisherICEEverConnected() bool {
return t.publisher.ICEHasEverConnected()
}
func (t *TransportManager) HasPublisherEverConnected() bool {
return t.publisher.HasEverConnected()
return t.publisher.PeerConnectionHasEverConnected()
}
func (t *TransportManager) PublisherFirstConnectedAt() time.Time {
return t.publisher.FirstConnectedAt()
return t.publisher.PeerConnectionFirstConnectedAt()
}
func (t *TransportManager) IsPublisherEstablished() bool {
@@ -272,19 +276,27 @@ func (t *TransportManager) GetSubscriberRTT() (float64, bool) {
}
}
func (t *TransportManager) HasSubscriberICEEverConnected() bool {
if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection {
return t.publisher.ICEHasEverConnected()
} else {
return t.subscriber.ICEHasEverConnected()
}
}
func (t *TransportManager) HasSubscriberEverConnected() bool {
if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection {
return t.publisher.HasEverConnected()
return t.publisher.PeerConnectionHasEverConnected()
} else {
return t.subscriber.HasEverConnected()
return t.subscriber.PeerConnectionHasEverConnected()
}
}
func (t *TransportManager) SubscriberFirstConnectedAt() time.Time {
if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection {
return t.publisher.FirstConnectedAt()
return t.publisher.PeerConnectionFirstConnectedAt()
} else {
return t.subscriber.FirstConnectedAt()
return t.subscriber.PeerConnectionFirstConnectedAt()
}
}
+75 -5
View File
@@ -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"
)
@@ -227,6 +227,8 @@ func (p ParticipantCloseReason) ToDisconnectReason() livekit.DisconnectReason {
}
}
// ---------------------------------------------
// 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.
@@ -292,6 +294,7 @@ func (s SignallingCloseReason) String() string {
}
// ---------------------------------------------
const (
ParticipantCloseKeyNormal = "normal"
ParticipantCloseKeyWHIP = "whip"
@@ -299,6 +302,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
@@ -370,10 +437,11 @@ type AddTrackParams struct {
}
type MoveToRoomParams struct {
RoomName livekit.RoomName
ParticipantID livekit.ParticipantID
Listener LocalParticipantListener
Helper LocalParticipantHelper
RoomName livekit.RoomName
ParticipantID livekit.ParticipantID
Listener LocalParticipantListener
TelemetryListener ParticipantTelemetryListener
Helper LocalParticipantHelper
}
type DataMessageCache struct {
@@ -425,6 +493,7 @@ type LocalParticipant interface {
GetPlayoutDelayConfig() *livekit.PlayoutDelay
GetPendingTrack(trackID livekit.TrackID) *livekit.TrackInfo
GetICEConnectionInfo() []*ICEConnectionInfo
HasICEConnected() bool
HasConnected() bool
GetEnabledPublishCodecs() []*livekit.Codec
GetPublisherICESessionUfrag() (string, error)
@@ -479,6 +548,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)
+1 -1
View File
@@ -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"
)
@@ -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"
)
@@ -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"
@@ -557,6 +557,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 {
@@ -774,6 +784,16 @@ type FakeLocalParticipant struct {
hasConnectedReturnsOnCall map[int]struct {
result1 bool
}
HasICEConnectedStub func() bool
hasICEConnectedMutex sync.RWMutex
hasICEConnectedArgsForCall []struct {
}
hasICEConnectedReturns struct {
result1 bool
}
hasICEConnectedReturnsOnCall map[int]struct {
result1 bool
}
HasPermissionStub func(livekit.TrackID, livekit.ParticipantIdentity) bool
hasPermissionMutex sync.RWMutex
hasPermissionArgsForCall []struct {
@@ -4403,6 +4423,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)]
@@ -5618,6 +5691,59 @@ func (fake *FakeLocalParticipant) HasConnectedReturnsOnCall(i int, result1 bool)
}{result1}
}
func (fake *FakeLocalParticipant) HasICEConnected() bool {
fake.hasICEConnectedMutex.Lock()
ret, specificReturn := fake.hasICEConnectedReturnsOnCall[len(fake.hasICEConnectedArgsForCall)]
fake.hasICEConnectedArgsForCall = append(fake.hasICEConnectedArgsForCall, struct {
}{})
stub := fake.HasICEConnectedStub
fakeReturns := fake.hasICEConnectedReturns
fake.recordInvocation("HasICEConnected", []interface{}{})
fake.hasICEConnectedMutex.Unlock()
if stub != nil {
return stub()
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) HasICEConnectedCallCount() int {
fake.hasICEConnectedMutex.RLock()
defer fake.hasICEConnectedMutex.RUnlock()
return len(fake.hasICEConnectedArgsForCall)
}
func (fake *FakeLocalParticipant) HasICEConnectedCalls(stub func() bool) {
fake.hasICEConnectedMutex.Lock()
defer fake.hasICEConnectedMutex.Unlock()
fake.HasICEConnectedStub = stub
}
func (fake *FakeLocalParticipant) HasICEConnectedReturns(result1 bool) {
fake.hasICEConnectedMutex.Lock()
defer fake.hasICEConnectedMutex.Unlock()
fake.HasICEConnectedStub = nil
fake.hasICEConnectedReturns = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) HasICEConnectedReturnsOnCall(i int, result1 bool) {
fake.hasICEConnectedMutex.Lock()
defer fake.hasICEConnectedMutex.Unlock()
fake.HasICEConnectedStub = nil
if fake.hasICEConnectedReturnsOnCall == nil {
fake.hasICEConnectedReturnsOnCall = make(map[int]struct {
result1 bool
})
}
fake.hasICEConnectedReturnsOnCall[i] = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) HasPermission(arg1 livekit.TrackID, arg2 livekit.ParticipantIdentity) bool {
fake.hasPermissionMutex.Lock()
ret, specificReturn := fake.hasPermissionReturnsOnCall[len(fake.hasPermissionArgsForCall)]
@@ -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"
)
+1 -1
View File
@@ -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"
@@ -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"
)
+1 -1
View File
@@ -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"
)
+10 -5
View File
@@ -184,7 +184,7 @@ func (r *WrappedReceiver) AddOnReady(f func()) {
type DummyReceiver struct {
receiver atomic.Value
trackID livekit.TrackID
trackInfo *livekit.TrackInfo
streamId string
codec webrtc.RTPCodecParameters
headerExtensions []webrtc.RTPHeaderExtensionParameter
@@ -201,9 +201,14 @@ type DummyReceiver struct {
redReceiver, primaryReceiver *DummyRedReceiver
}
func NewDummyReceiver(trackID livekit.TrackID, streamId string, codec webrtc.RTPCodecParameters, headerExtensions []webrtc.RTPHeaderExtensionParameter) *DummyReceiver {
func NewDummyReceiver(
trackInfo *livekit.TrackInfo,
streamId string,
codec webrtc.RTPCodecParameters,
headerExtensions []webrtc.RTPHeaderExtensionParameter,
) *DummyReceiver {
return &DummyReceiver{
trackID: trackID,
trackInfo: trackInfo,
streamId: streamId,
codec: codec,
headerExtensions: headerExtensions,
@@ -262,7 +267,7 @@ func (d *DummyReceiver) Upgrade(receiver sfu.TrackReceiver) {
}
func (d *DummyReceiver) TrackID() livekit.TrackID {
return d.trackID
return livekit.TrackID(d.trackInfo.Sid)
}
func (d *DummyReceiver) StreamID() string {
@@ -391,7 +396,7 @@ func (d *DummyReceiver) TrackInfo() *livekit.TrackInfo {
if receiver := d.getReceiver(); receiver != nil {
return receiver.TrackInfo()
}
return nil
return d.trackInfo
}
func (d *DummyReceiver) UpdateTrackInfo(ti *livekit.TrackInfo) {
+33 -11
View File
@@ -16,6 +16,7 @@ package service_test
import (
"context"
"flag"
"fmt"
"log"
"net"
@@ -31,24 +32,43 @@ import (
var Docker dockertest.ClosablePool
func TestMain(m *testing.M) {
ctx := context.Background()
pool, err := dockertest.NewPool(ctx, "")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
// go test -docker=false ./pkg/service skips the tests that need a docker
// daemon, for a checkout without one. Running them is the default: a run that
// quietly covers less than the last one is worse than a run that stops, so a
// daemon that should be there and is not still fails the whole package.
var useDocker = flag.Bool("docker", true, "run the tests that need a docker daemon")
// uses pool to try to connect to Docker
_, err = pool.Client().Ping(ctx, mobyclient.PingOptions{})
if err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
func TestMain(m *testing.M) {
// m.Run would parse them, but the flag is read before that
flag.Parse()
if *useDocker {
ctx := context.Background()
pool, err := dockertest.NewPool(ctx, "")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
// uses pool to try to connect to Docker
_, err = pool.Client().Ping(ctx, mobyclient.PingOptions{})
if err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
}
Docker = pool
}
Docker = pool
code := m.Run()
os.Exit(code)
}
func requireDocker(t testing.TB) {
t.Helper()
if !*useDocker {
t.Skip("this test needs a docker daemon, and -docker=false says there is none")
}
}
func waitTCPPort(t testing.TB, addr string) {
if err := Docker.Retry(t.Context(), 30*time.Second, func() error {
conn, err := net.Dial("tcp", addr)
@@ -66,6 +86,8 @@ func waitTCPPort(t testing.TB, addr string) {
var redisLast atomic.Uint32
func runRedis(t testing.TB) string {
requireDocker(t)
c, err := Docker.Run(t.Context(),
"redis",
dockertest.WithName(fmt.Sprintf("lktest-redis-%d", redisLast.Inc())),
+18 -7
View File
@@ -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,20 +135,27 @@ 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
urlStr = urlObj.String()
}
reqID := RequestID(ctx)
var sk string
if req.InputType != livekit.IngressInput_URL_INPUT {
sk = guid.New("")
}
info := &livekit.IngressInfo{
IngressId: guid.New(utils.IngressPrefix),
IngressId: DeterministicID(utils.IngressPrefix, reqID),
Name: req.Name,
StreamKey: sk,
Url: urlStr,
@@ -192,11 +201,13 @@ func (s *IngressService) CreateIngressWithUrl(ctx context.Context, urlStr string
}
// The Ingress instance will create the ingress object when handling the URL pull ingress
} else {
// TODO-jie: ingress retry idempotency: generate ingress key by request-id, and return the ingress object from CreateIngress.
_, err = s.io.CreateIngress(ctx, info)
var resp *rpc.CreateIngressResponse
resp, err = s.io.CreateIngress(ctx, info)
switch err {
case nil:
break
if resp.GetInfo() != nil {
info = resp.GetInfo()
}
case ingress.ErrIngressOutOfDate:
// Error returned if the ingress was already created by the ingress service
err = nil
+96
View File
@@ -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")
})
}
+4 -4
View File
@@ -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()
@@ -692,12 +692,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)
@@ -943,7 +943,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
}
+16 -3
View File
@@ -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(),
+143 -63
View File
@@ -44,6 +44,17 @@ import (
"github.com/livekit/livekit-server/pkg/utils"
)
const (
// how long the response source is drained after the request direction is gone,
// applies only when the source is not closed by the relay, i. e. when there is
// no way to tell that everything pending has been read
responseFlushTimeout = 250 * time.Millisecond
// how long the response pump is given to stop, a bit more than the drain deadline
// so that it can finish the write it is in when that deadline expires
responsePumpDoneTimeout = 2 * responseFlushTimeout
)
type RTCService struct {
router routing.MessageRouter
roomAllocator RoomAllocator
@@ -381,7 +392,7 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
var cr connectionResult
var initialResponse *livekit.SignalResponse
for attempt := 0; attempt < s.config.SignalRelay.ConnectAttempts; attempt++ {
connectionTimeout := 3 * time.Second * time.Duration(attempt+1)
connectionTimeout := time.Duration(3+attempt) * time.Second
ctx := utils.ContextWithAttempt(r.Context(), attempt)
cr, initialResponse, err = s.startConnection(ctx, roomName, pi, connectionTimeout)
if err == nil || errors.Is(err, context.Canceled) {
@@ -428,13 +439,34 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
closedByClient := atomic.NewBool(false)
done := make(chan struct{})
// closed by the response pump when it has stopped writing to the web socket
responsePumpDone := make(chan struct{})
responsePumpStarted := false
var sigConn *WSSignalConnection
// function exits when websocket terminates, it'll close the event reading off of request sink and response source as well
defer func() {
resolveLogger(true)
pLogger.Debugw("finishing WS connection", "closedByClient", closedByClient.Load())
cr.ResponseSource.Close()
cr.RequestSink.Close()
// signal the response pump before anything else so that it can flush responses
// the participant queued on its way out, a leave request sent just before the
// signalling connection was closed (on migration for example) is dropped otherwise
close(done)
cr.RequestSink.Close()
if responsePumpStarted {
select {
case <-responsePumpDone:
case <-time.After(responsePumpDoneTimeout):
pLogger.Debugw("timed out waiting for response pump to finish")
}
}
cr.ResponseSource.Close()
// close the web socket on all paths, even when the response pump is wedged
// writing to an unresponsive client
if sigConn != nil {
sigConn.CloseWithReason("")
}
signalStats.Stop()
}()
@@ -466,8 +498,7 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
}()
// websocket established
sigConn := NewWSSignalConnection(conn, s.limits.SignalMessageSizeLimit)
defer sigConn.CloseWithReason("")
sigConn = NewWSSignalConnection(conn, s.limits.SignalMessageSizeLimit)
pLogger.Debugw("sending initial response", "response", logger.Proto(initialResponse))
count, err := sigConn.WriteResponse(initialResponse)
if err != nil {
@@ -491,9 +522,79 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
"nodeSelectionReason", cr.NodeSelectionReason,
)
// writes one response from the response source to the web socket,
// returns false if the response pump should stop
writeResponse := func(msg proto.Message) bool {
res, ok := msg.(*livekit.SignalResponse)
if !ok {
pLogger.Errorw(
"unexpected message type", nil,
"type", fmt.Sprintf("%T", msg),
)
return true
}
switch m := res.Message.(type) {
case *livekit.SignalResponse_Offer:
pLogger.Debugw("sending offer", "offer", logger.Proto(res))
case *livekit.SignalResponse_Answer:
pLogger.Debugw("sending answer", "answer", logger.Proto(res))
case *livekit.SignalResponse_Join:
pLogger.Debugw("sending join", "join", logger.Proto(res))
signalStats.ResolveRoom(m.Join.GetRoom())
signalStats.ResolveParticipant(m.Join.GetParticipant())
case *livekit.SignalResponse_RoomUpdate:
updateRoomID := livekit.RoomID(m.RoomUpdate.GetRoom().GetSid())
if updateRoomID != "" {
roomID = updateRoomID
resolveLogger(false)
}
pLogger.Debugw("sending room update", "roomUpdate", logger.Proto(res))
signalStats.ResolveRoom(m.RoomUpdate.GetRoom())
case *livekit.SignalResponse_Update:
pLogger.Debugw("sending participant update", "participantUpdate", logger.Proto(res))
case *livekit.SignalResponse_RoomMoved:
resetLogger()
signalStats.Reset()
roomName = livekit.RoomName(m.RoomMoved.GetRoom().GetName())
moveRoomID := livekit.RoomID(m.RoomMoved.GetRoom().GetSid())
if moveRoomID != "" {
roomID = moveRoomID
}
participantIdentity = livekit.ParticipantIdentity(m.RoomMoved.GetParticipant().GetIdentity())
pID = livekit.ParticipantID(m.RoomMoved.GetParticipant().GetSid())
resolveLogger(false)
signalStats.ResolveRoom(m.RoomMoved.GetRoom())
signalStats.ResolveParticipant(m.RoomMoved.GetParticipant())
pLogger.Debugw("sending room moved", "roomMoved", logger.Proto(res))
default:
pLogger.Debugw("sending signal response", "response", logger.Proto(res))
}
if count, err := sigConn.WriteResponse(res); err != nil {
pLogger.Warnw("error writing to websocket", err)
return false
} else {
signalStats.AddBytes(uint64(count), true)
}
return true
}
// handle responses
responsePumpStarted = true
go func() {
defer func() {
close(responsePumpDone)
// when the source is terminated, this means Participant.Close had been called and RTC connection is done
// we would terminate the signal connection as well
sigConn.CloseWithReason("")
@@ -506,72 +607,23 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
for {
select {
case <-done:
// the request direction is gone, flush what the participant queued on its
// way out unless the client is the one that went away
if !closedByClient.Load() {
if !drainMessageSource(cr.ResponseSource, responseFlushTimeout, writeResponse) {
pLogger.Debugw("could not drain response source fully")
}
}
return
case msg := <-cr.ResponseSource.ReadChan():
if msg == nil {
resolveLogger(true)
pLogger.Debugw("nothing to read from response source")
return
}
res, ok := msg.(*livekit.SignalResponse)
if !ok {
pLogger.Errorw(
"unexpected message type", nil,
"type", fmt.Sprintf("%T", msg),
)
continue
}
switch m := res.Message.(type) {
case *livekit.SignalResponse_Offer:
pLogger.Debugw("sending offer", "offer", logger.Proto(res))
case *livekit.SignalResponse_Answer:
pLogger.Debugw("sending answer", "answer", logger.Proto(res))
case *livekit.SignalResponse_Join:
pLogger.Debugw("sending join", "join", logger.Proto(res))
signalStats.ResolveRoom(m.Join.GetRoom())
signalStats.ResolveParticipant(m.Join.GetParticipant())
case *livekit.SignalResponse_RoomUpdate:
updateRoomID := livekit.RoomID(m.RoomUpdate.GetRoom().GetSid())
if updateRoomID != "" {
roomID = updateRoomID
resolveLogger(false)
}
pLogger.Debugw("sending room update", "roomUpdate", logger.Proto(res))
signalStats.ResolveRoom(m.RoomUpdate.GetRoom())
case *livekit.SignalResponse_Update:
pLogger.Debugw("sending participant update", "participantUpdate", logger.Proto(res))
case *livekit.SignalResponse_RoomMoved:
resetLogger()
signalStats.Reset()
roomName = livekit.RoomName(m.RoomMoved.GetRoom().GetName())
moveRoomID := livekit.RoomID(m.RoomMoved.GetRoom().GetSid())
if moveRoomID != "" {
roomID = moveRoomID
}
participantIdentity = livekit.ParticipantIdentity(m.RoomMoved.GetParticipant().GetIdentity())
pID = livekit.ParticipantID(m.RoomMoved.GetParticipant().GetSid())
resolveLogger(false)
signalStats.ResolveRoom(m.RoomMoved.GetRoom())
signalStats.ResolveParticipant(m.RoomMoved.GetParticipant())
pLogger.Debugw("sending room moved", "roomMoved", logger.Proto(res))
default:
pLogger.Debugw("sending signal response", "response", logger.Proto(res))
}
if count, err := sigConn.WriteResponse(res); err != nil {
pLogger.Warnw("error writing to websocket", err)
if !writeResponse(msg) {
return
} else {
signalStats.AddBytes(uint64(count), true)
}
}
}
@@ -634,6 +686,34 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
}
}
// drainMessageSource writes messages that are still queued in source using write.
// It is used when tearing a signalling connection down, responses the participant queued
// on its way out, a leave request on migration for example, would be dropped otherwise.
//
// The producer writes all pending messages into the source before closing it, so draining
// till the source is closed is a complete flush. The deadline is a backstop for the cases
// where the source is not closed, i. e. when there is no way to tell that everything
// pending has been read. Returns true if the source was drained fully.
func drainMessageSource(source routing.MessageSource, timeout time.Duration, write func(proto.Message) bool) bool {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
select {
case msg := <-source.ReadChan():
if msg == nil {
return true
}
if !write(msg) {
return false
}
case <-deadline.C:
return false
}
}
}
func (s *RTCService) DrainConnections(interval time.Duration, force bool) {
s.mu.Lock()
conns := maps.Clone(s.connections)
+109
View File
@@ -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 service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/routing"
)
func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse {
return &livekit.SignalResponse{
Message: &livekit.SignalResponse_Leave{
Leave: &livekit.LeaveRequest{
Action: action,
Reason: livekit.DisconnectReason_MIGRATION,
},
},
}
}
func TestDrainMessageSource(t *testing.T) {
collect := func(msgs *[]proto.Message) func(proto.Message) bool {
return func(msg proto.Message) bool {
*msgs = append(*msgs, msg)
return true
}
}
t.Run("drains queued messages of a closed source", func(t *testing.T) {
source := routing.NewDefaultMessageChannel("CO_test")
require.NoError(t, source.WriteMessage(leaveResponse(livekit.LeaveRequest_RESUME)))
require.NoError(t, source.WriteMessage(leaveResponse(livekit.LeaveRequest_RECONNECT)))
source.Close()
var got []proto.Message
require.True(t, drainMessageSource(source, time.Second, collect(&got)))
require.Len(t, got, 2)
require.Equal(
t,
livekit.LeaveRequest_RESUME,
got[0].(*livekit.SignalResponse).GetLeave().GetAction(),
)
require.Equal(
t,
livekit.LeaveRequest_RECONNECT,
got[1].(*livekit.SignalResponse).GetLeave().GetAction(),
)
})
t.Run("drains messages written while draining", func(t *testing.T) {
source := routing.NewDefaultMessageChannel("CO_test")
// mimics the relay pushing a message that was still in flight when the
// request direction went away, and closing the source right after
go func() {
time.Sleep(20 * time.Millisecond)
_ = source.WriteMessage(leaveResponse(livekit.LeaveRequest_RESUME))
source.Close()
}()
var got []proto.Message
require.True(t, drainMessageSource(source, time.Second, collect(&got)))
require.Len(t, got, 1)
})
t.Run("gives up on the deadline when the source stays open", func(t *testing.T) {
source := routing.NewDefaultMessageChannel("CO_test")
require.NoError(t, source.WriteMessage(leaveResponse(livekit.LeaveRequest_RESUME)))
var got []proto.Message
start := time.Now()
require.False(t, drainMessageSource(source, 50*time.Millisecond, collect(&got)))
require.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond)
// what was queued is still flushed
require.Len(t, got, 1)
})
t.Run("stops when the write fails", func(t *testing.T) {
source := routing.NewDefaultMessageChannel("CO_test")
require.NoError(t, source.WriteMessage(leaveResponse(livekit.LeaveRequest_RESUME)))
require.NoError(t, source.WriteMessage(leaveResponse(livekit.LeaveRequest_RECONNECT)))
source.Close()
var got []proto.Message
require.False(t, drainMessageSource(source, time.Second, func(msg proto.Message) bool {
got = append(got, msg)
return false
}))
require.Len(t, got, 1)
})
}
@@ -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)
+8 -4
View File
@@ -22,7 +22,6 @@ import (
"github.com/dennwc/iters"
"github.com/twitchtv/twirp"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
@@ -681,7 +680,7 @@ func (s *SIPService) CreateSIPParticipantRequest(ctx context.Context, req *livek
return internalReq, nil
}
func (s *SIPService) TransferSIPParticipant(ctx context.Context, req *livekit.TransferSIPParticipantRequest) (*emptypb.Empty, error) {
func (s *SIPService) TransferSIPParticipant(ctx context.Context, req *livekit.TransferSIPParticipantRequest) (*livekit.TransferSIPParticipantResponse, error) {
AppendLogFields(ctx,
"room", req.RoomName,
"participant", req.ParticipantIdentity,
@@ -722,13 +721,18 @@ func (s *SIPService) TransferSIPParticipant(ctx context.Context, req *livekit.Tr
// own default (which could outlive us).
ireq.RingingTimeout = durationpb.New(timeout)
_, err = s.psrpcClient.TransferSIPParticipant(ctx, ireq.SipCallId, ireq, psrpc.WithRequestTimeout(timeout))
iresp, err := s.psrpcClient.TransferSIPParticipant(ctx, ireq.SipCallId, ireq, psrpc.WithRequestTimeout(timeout))
if err != nil {
log.Errorw("cannot transfer sip participant", err)
return nil, wrapSIPContextError(err)
}
return &emptypb.Empty{}, nil
return &livekit.TransferSIPParticipantResponse{
TransferId: iresp.GetTransferId(),
Status: iresp.GetStatus(),
Reason: iresp.GetReason(),
SipStatus: iresp.GetSipStatus(),
}, nil
}
func (s *SIPService) transferSIPParticipantRequest(ctx context.Context, req *livekit.TransferSIPParticipantRequest, log logger.UnlikelyLogger) (*rpc.InternalTransferSIPParticipantRequest, error) {
+64 -21
View File
@@ -26,6 +26,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"
@@ -162,26 +163,9 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
}
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 +178,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 +205,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
}
+136
View File
@@ -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)
}
+1 -2
View File
@@ -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{},
}
+4 -3
View File
@@ -193,11 +193,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 psrpc.NewRedisMessageBus(rc, opts...)
}
func getEgressStore(s ObjectStore) EgressStore {
+28 -27
View File
@@ -39,14 +39,14 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live
return nil, err
}
nodeID := getNodeID(currentNode)
messageBus := getMessageBus(universalClient)
psrpcConfig := getPSRPCConfig(conf)
v := getMessageBus(universalClient, psrpcConfig)
signalRelayConfig := getSignalRelayConfig(conf)
signalClient, err := routing.NewSignalClient(nodeID, messageBus, signalRelayConfig)
signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig)
if err != nil {
return nil, err
}
psrpcConfig := getPSRPCConfig(conf)
clientParams := getPSRPCClientParams(psrpcConfig, messageBus)
clientParams := getPSRPCClientParams(psrpcConfig, v)
roomConfig := getRoomConfig(conf)
roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig)
if err != nil {
@@ -80,57 +80,57 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live
}
analyticsService := telemetry.NewAnalyticsService(conf, currentNode)
telemetryService := createTelemetryService(queuedNotifier, analyticsService)
ioInfoService, err := NewIOInfoService(messageBus, egressStore, ingressStore, sipStore, telemetryService)
ioInfoService, err := NewIOInfoService(v, egressStore, ingressStore, sipStore, telemetryService)
if err != nil {
return nil, err
}
rtcEgressLauncher := NewEgressLauncher(egressClient, ioInfoService, objectStore)
topicFormatter := rpc.NewTopicFormatter()
v, err := rpc.NewTypedRoomClient(clientParams)
v2, err := rpc.NewTypedRoomClient(clientParams)
if err != nil {
return nil, err
}
v2, err := rpc.NewTypedParticipantClient(clientParams)
v3, err := rpc.NewTypedParticipantClient(clientParams)
if err != nil {
return nil, err
}
roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, v, v2)
roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, v2, v3)
if err != nil {
return nil, err
}
v3, err := rpc.NewTypedAgentDispatchInternalClient(clientParams)
v4, err := rpc.NewTypedAgentDispatchInternalClient(clientParams)
if err != nil {
return nil, err
}
agentDispatchService := NewAgentDispatchService(limitConfig, v3, topicFormatter, roomAllocator, router)
agentDispatchService := NewAgentDispatchService(limitConfig, v4, topicFormatter, roomAllocator, router)
egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService)
ingressConfig := getIngressConfig(conf)
ingressClient, err := rpc.NewIngressClient(clientParams)
if err != nil {
return nil, err
}
ingressService := NewIngressService(ingressConfig, nodeID, messageBus, ingressClient, ingressStore, ioInfoService, telemetryService)
ingressService := NewIngressService(ingressConfig, nodeID, v, ingressClient, ingressStore, ioInfoService, telemetryService)
sipConfig := getSIPConfig(conf)
sipClient, err := newSIPClient(clientParams)
if err != nil {
return nil, err
}
sipService := NewSIPService(sipConfig, nodeID, messageBus, sipClient, sipStore, roomService, telemetryService)
sipService := NewSIPService(sipConfig, nodeID, v, sipClient, sipStore, roomService, telemetryService)
rtcService := NewRTCService(conf, roomAllocator, router, telemetryService)
v4, err := rpc.NewTypedWHIPParticipantClient(clientParams)
v5, err := rpc.NewTypedWHIPParticipantClient(clientParams)
if err != nil {
return nil, err
}
serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, v4)
serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, v5)
if err != nil {
return nil, err
}
agentService, err := NewAgentService(conf, currentNode, messageBus, keyProvider)
agentService, err := NewAgentService(conf, currentNode, v, keyProvider)
if err != nil {
return nil, err
}
agentConfig := getAgentConfig(conf)
client, err := agent.NewAgentClient(messageBus, agentConfig)
client, err := agent.NewAgentClient(v, agentConfig)
if err != nil {
return nil, err
}
@@ -138,16 +138,16 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live
timedVersionGenerator := utils.NewDefaultTimedVersionGenerator()
turnAuthHandler := NewTURNAuthHandler(keyProvider)
forwardStats := createForwardStats(conf)
roomManager, err := NewLocalRoomManager(conf, objectStore, currentNode, router, roomAllocator, telemetryService, client, agentStore, rtcEgressLauncher, timedVersionGenerator, turnAuthHandler, messageBus, forwardStats)
roomManager, err := NewLocalRoomManager(conf, objectStore, currentNode, router, roomAllocator, telemetryService, client, agentStore, rtcEgressLauncher, timedVersionGenerator, turnAuthHandler, v, forwardStats)
if err != nil {
return nil, err
}
signalServer, err := NewDefaultSignalServer(currentNode, messageBus, signalRelayConfig, router, roomManager)
signalServer, err := NewDefaultSignalServer(currentNode, v, signalRelayConfig, router, roomManager)
if err != nil {
return nil, err
}
v5 := getTURNAuthHandlerFunc(turnAuthHandler)
server, err := newInProcessTurnServer(conf, v5)
v6 := getTURNAuthHandlerFunc(turnAuthHandler)
server, err := newInProcessTurnServer(conf, v6)
if err != nil {
return nil, err
}
@@ -164,14 +164,14 @@ func InitializeRouter(conf *config.Config, currentNode routing.LocalNode) (routi
return nil, err
}
nodeID := getNodeID(currentNode)
messageBus := getMessageBus(universalClient)
psrpcConfig := getPSRPCConfig(conf)
v := getMessageBus(universalClient, psrpcConfig)
signalRelayConfig := getSignalRelayConfig(conf)
signalClient, err := routing.NewSignalClient(nodeID, messageBus, signalRelayConfig)
signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig)
if err != nil {
return nil, err
}
psrpcConfig := getPSRPCConfig(conf)
clientParams := getPSRPCClientParams(psrpcConfig, messageBus)
clientParams := getPSRPCClientParams(psrpcConfig, v)
roomConfig := getRoomConfig(conf)
roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig)
if err != nil {
@@ -254,11 +254,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 psrpc.NewRedisMessageBus(rc, opts...)
}
func getEgressStore(s ObjectStore) EgressStore {
+86
View File
@@ -72,6 +72,24 @@ type Buffer struct {
primaryBufferForRTX *Buffer
rtxPktBuf []byte
streamInfoProbe *StreamInfoProbe
warnedPendingOverflow bool
}
// StreamInfoProbe identifies a stream from the mid/rid/rsid header extensions of its
// packets. It runs on the write path, i. e. as SRTP pushes into this buffer, because
// nothing reads remote streams through pion's interceptor chain.
type StreamInfoProbe struct {
MidExtID uint8
RidExtID uint8
RsidExtID uint8
// Tries bounds how many packets are inspected before giving up.
Tries int
// OnFound is called at most once, in a goroutine, as it can re-enter this buffer.
OnFound func(ssrc uint32, mid, rid, rsid string)
}
func NewBuffer(ssrc uint32, maxVideoPkts, maxAudioPkts int) *Buffer {
@@ -166,6 +184,10 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) {
return
}
if b.streamInfoProbe != nil {
b.probeStreamInfoLocked(&rtpPacket)
}
// handle RTX packet
if pb := b.primaryBufferForRTX; pb != nil {
b.Unlock()
@@ -191,6 +213,17 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) {
overflow := len(b.pPackets) - max(b.BufferBase.MaxVideoPkts(), b.BufferBase.MaxAudioPkts())
if overflow > 0 {
startIdx = overflow
// a stream that keeps arriving but never binds drops every packet from here
// on; for an RTX stream it means the pairing was never established
if !b.warnedPendingOverflow {
b.warnedPendingOverflow = true
b.logger.Warnw(
"unbound buffer overflowing, dropping packets", nil,
"ssrc", b.BufferBase.SSRC(),
"pending", len(b.pPackets),
)
}
}
b.pPackets = append(b.pPackets[startIdx:], pendingPacket{
packet: packet,
@@ -213,6 +246,59 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) {
return
}
// SetStreamInfoProbe installs probe and runs it over packets already queued.
func (b *Buffer) SetStreamInfoProbe(probe *StreamInfoProbe) {
b.Lock()
defer b.Unlock()
b.streamInfoProbe = probe
for _, pp := range b.pPackets {
if b.streamInfoProbe == nil {
return
}
var rtpPacket rtp.Packet
if err := rtpPacket.Unmarshal(pp.packet); err != nil {
continue
}
b.probeStreamInfoLocked(&rtpPacket)
}
}
// probeStreamInfoLocked inspects one packet, clearing the probe once the stream is
// identified or the try budget runs out.
func (b *Buffer) probeStreamInfoLocked(rtpPacket *rtp.Packet) {
probe := b.streamInfoProbe
var mid, rid, rsid string
if ext := rtpPacket.GetExtension(probe.MidExtID); ext != nil {
mid = string(ext)
}
if ext := rtpPacket.GetExtension(probe.RidExtID); ext != nil {
rid = string(ext)
}
if ext := rtpPacket.GetExtension(probe.RsidExtID); ext != nil {
rsid = string(ext)
}
if mid != "" && (rid != "" || rsid != "") {
b.streamInfoProbe = nil
b.logger.Debugw("stream found", "ssrc", rtpPacket.SSRC, "mid", mid, "rid", rid, "rsid", rsid)
go probe.OnFound(rtpPacket.SSRC, mid, rid, rsid)
return
}
// ignore padding only packets for probe count
if rtpPacket.Padding && len(rtpPacket.Payload) == 0 {
return
}
if probe.Tries--; probe.Tries <= 0 {
b.streamInfoProbe = nil
}
}
func (b *Buffer) SetPrimaryBufferForRTX(primaryBuffer *Buffer) {
b.Lock()
b.primaryBufferForRTX = primaryBuffer
+32 -8
View File
@@ -60,12 +60,18 @@ 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
activeDecodeTargetsExtSeq uint64
activeDecodeTargetsMask uint32
frameChecker *FrameIntegrityChecker
ddNotFoundCount atomic.Uint32
@@ -153,12 +159,12 @@ 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,
)
@@ -188,7 +194,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,
@@ -252,6 +275,7 @@ func (r *DependencyDescriptorParser) restart() {
r.frameChecker = NewFrameIntegrityChecker(integrityCheckFrame, integrityCheckPkt)
r.structure = nil
r.structureExtFrameNum = 0
r.structureChangeExtFrameNum = 0
r.activeDecodeTargetsExtSeq = 0
r.activeDecodeTargetsMask = 0
r.decodeTargets = r.decodeTargets[:0]
@@ -0,0 +1,188 @@
// 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)
}
func (f *ddTestFeeder) feed(
frameNumber uint16,
template *dd.FrameDependencyTemplate,
attachedStructure *dd.FrameDependencyStructure,
) (*ExtDependencyDescriptor, error) {
f.t.Helper()
ddVal := &dd.DependencyDescriptor{
FirstPacketInFrame: true,
LastPacketInFrame: true,
FrameNumber: frameNumber,
FrameDependencies: template,
AttachedStructure: attachedStructure,
}
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)
}
+15
View File
@@ -118,6 +118,21 @@ func (f *Factory) GetRTCPReader(ssrc uint32) *RTCPReader {
return f.rtcpReaders[ssrc]
}
// SetStreamInfoProbe installs probe on the buffer of ssrc, reporting whether that
// buffer exists. False means the stream can never be identified.
func (f *Factory) SetStreamInfoProbe(ssrc uint32, probe *StreamInfoProbe) bool {
f.RLock()
buffer := f.rtpBuffers[ssrc]
f.RUnlock()
if buffer == nil {
return false
}
buffer.SetStreamInfoProbe(probe)
return true
}
func (f *Factory) SetRTXPair(repair, base uint32, rsid string) {
f.Lock()
repairBuffer, baseBuffer := f.rtpBuffers[repair], f.rtpBuffers[base]
+15 -5
View File
@@ -1059,9 +1059,10 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 {
}
payload = payload[:len(tp.codecBytes)+n]
trailerStripped := 0
if d.params.StripPacketTrailer {
if strip := packettrailer.StripTrailer(payload, tp.marker); strip > 0 {
payload = payload[:len(payload)-strip]
if trailerStripped = packettrailer.StripTrailer(payload, tp.marker || tp.isEndOfLayerFrame); trailerStripped > 0 {
payload = payload[:len(payload)-trailerStripped]
}
}
@@ -1132,6 +1133,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 {
tp.incomingHeaderSize,
tp.ddBytes,
actBytes,
trailerStripped,
)
}
@@ -2243,9 +2245,17 @@ func (d *DownTrack) retransmitPacket(epm *extPacketMeta, sourcePkt []byte, isPro
payload = payload[:rtxOffset+int(epm.numCodecBytesOut)+len(pkt.Payload)-int(epm.numCodecBytesIn)]
}
if d.params.StripPacketTrailer {
if strip := packettrailer.StripTrailer(payload[rtxOffset:], epm.marker); strip > 0 {
payload = payload[:len(payload)-strip]
// replay the strip done on the original transmission to keep the retransmitted
// payload byte identical to it
if epm.trailerStripped != 0 {
if int(epm.trailerStripped) > len(payload)-rtxOffset {
d.params.Logger.Warnw(
"recorded packet trailer size overflows payload", errPayloadOverflow,
"trailerStripped", epm.trailerStripped,
"payloadSize", len(payload)-rtxOffset,
)
} else {
payload = payload[:len(payload)-int(epm.trailerStripped)]
}
}
+84 -183
View File
@@ -45,12 +45,8 @@ import (
"testing"
"time"
"github.com/pion/interceptor"
"github.com/pion/logging"
"github.com/pion/rtcp"
"github.com/pion/rtp"
"github.com/pion/sdp/v3"
"github.com/pion/transport/v4/vnet"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
@@ -66,168 +62,13 @@ import (
"github.com/livekit/livekit-server/pkg/sfu/bwe/sendsidebwe"
"github.com/livekit/livekit-server/pkg/sfu/ccutils"
"github.com/livekit/livekit-server/pkg/sfu/pacer"
"github.com/livekit/livekit-server/pkg/sfu/packettrailer"
"github.com/livekit/livekit-server/pkg/sfu/sfufakes"
"github.com/livekit/livekit-server/pkg/sfu/streamallocator"
"github.com/livekit/livekit-server/pkg/sfu/testutils"
"github.com/livekit/livekit-server/pkg/testutils/vnettest"
)
// -----------------------------------------------------------------------------
// vnet harness
// -----------------------------------------------------------------------------
type vnetHarness struct {
wan *vnet.Router
offerNet *vnet.Net
answerNet *vnet.Net
}
func buildVNet(t *testing.T) *vnetHarness {
t.Helper()
wan, err := vnet.NewRouter(&vnet.RouterConfig{
CIDR: "1.2.3.0/24",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
require.NoError(t, err)
offerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.4"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(offerNet))
answerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.5"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(answerNet))
require.NoError(t, wan.Start())
t.Cleanup(func() { _ = wan.Stop() })
return &vnetHarness{wan: wan, offerNet: offerNet, answerNet: answerNet}
}
// mediaEngineConfig describes what codecs / header extensions to register on a PC.
type mediaEngineConfig struct {
video bool // register VP8 (+ RTX); otherwise register opus
headerExtensions bool // register abs-send-time + transport-cc header extensions
}
func newMediaPC(t *testing.T, net *vnet.Net, factory *buffer.Factory, cfg mediaEngineConfig) *webrtc.PeerConnection {
t.Helper()
me := &webrtc.MediaEngine{}
if cfg.video {
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, RTCPFeedback: videoRTCPFeedback(),
},
PayloadType: 96,
}, webrtc.RTPCodecTypeVideo))
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeRTX, ClockRate: 90000, SDPFmtpLine: "apt=96",
},
PayloadType: 97,
}, webrtc.RTPCodecTypeVideo))
} else {
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2,
},
PayloadType: 111,
}, webrtc.RTPCodecTypeAudio))
}
if cfg.headerExtensions {
kind := webrtc.RTPCodecTypeAudio
if cfg.video {
kind = webrtc.RTPCodecTypeVideo
}
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.ABSSendTimeURI}, kind))
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.TransportCCURI}, kind))
}
// no pion default interceptors: the DownTrack/pacer fill abs-send-time and
// transport-cc themselves, and the tests drive RTCP feedback synthetically, so
// pion's own feedback generators would only add nondeterminism.
ir := &interceptor.Registry{}
se := webrtc.SettingEngine{}
se.SetNet(net)
se.SetICETimeouts(time.Second, time.Second, 200*time.Millisecond)
se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
if factory != nil {
se.BufferFactory = factory.GetOrNew
}
api := webrtc.NewAPI(
webrtc.WithMediaEngine(me),
webrtc.WithInterceptorRegistry(ir),
webrtc.WithSettingEngine(se),
)
pc, err := api.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
t.Cleanup(func() { _ = pc.Close() })
return pc
}
func videoRTCPFeedback() []webrtc.RTCPFeedback {
return []webrtc.RTCPFeedback{
{Type: "nack"},
{Type: "nack", Parameter: "pli"},
{Type: webrtc.TypeRTCPFBTransportCC},
{Type: webrtc.TypeRTCPFBGoogREMB},
}
}
// signalPair performs a full offer/answer exchange between two PCs (adapted from
// pion's own test helper) and waits for both to reach the connected state.
func signalPair(t *testing.T, offerer, answerer *webrtc.PeerConnection) {
t.Helper()
connected := untilConnected(offerer, answerer)
offer, err := offerer.CreateOffer(nil)
require.NoError(t, err)
gatherOffer := webrtc.GatheringCompletePromise(offerer)
require.NoError(t, offerer.SetLocalDescription(offer))
<-gatherOffer
require.NoError(t, answerer.SetRemoteDescription(*offerer.LocalDescription()))
answer, err := answerer.CreateAnswer(nil)
require.NoError(t, err)
gatherAnswer := webrtc.GatheringCompletePromise(answerer)
require.NoError(t, answerer.SetLocalDescription(answer))
<-gatherAnswer
require.NoError(t, offerer.SetRemoteDescription(*answerer.LocalDescription()))
select {
case <-connected:
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for peer connections to connect")
}
}
func untilConnected(pcs ...*webrtc.PeerConnection) <-chan struct{} {
var wg sync.WaitGroup
wg.Add(len(pcs))
for _, pc := range pcs {
var once sync.Once
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateConnected {
once.Do(wg.Done)
}
})
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
return done
}
// -----------------------------------------------------------------------------
// packet capture on the far side
// -----------------------------------------------------------------------------
@@ -319,7 +160,7 @@ var (
PayloadType: 111,
}
vp8CodecParams = webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, RTCPFeedback: videoRTCPFeedback()},
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, RTCPFeedback: vnettest.VideoRTCPFeedback()},
PayloadType: 96,
}
)
@@ -335,11 +176,11 @@ 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 *vnetHarness, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg mediaEngineConfig) *downTrackHarness {
func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg vnettest.MediaEngineConfig) *downTrackHarness {
t.Helper()
sender := newMediaPC(t, h.offerNet, factory, meCfg)
sub := newMediaPC(t, h.answerNet, factory, meCfg)
sender := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.OfferNet, MediaEngine: meCfg, BufferFactory: factory.GetOrNew})
sub := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.AnswerNet, MediaEngine: meCfg, BufferFactory: factory.GetOrNew})
capture := captureTrack(sub)
rcv := newFakeTrackReceiver(codecParams)
@@ -360,7 +201,7 @@ func newBoundDownTrack(t *testing.T, h *vnetHarness, factory *buffer.Factory, co
require.NoError(t, err)
dt.SetTransceiver(tr)
signalPair(t, sender, sub)
vnettest.SignalPair(t, sender, sub)
dt.SetConnected()
require.Eventually(t, func() bool {
@@ -392,10 +233,10 @@ func distinctivePayload(seed byte, n int) []byte {
// -----------------------------------------------------------------------------
func TestPionVNetForwardingSpike(t *testing.T) {
h := buildVNet(t)
h := vnettest.NewHosts(t)
sender := newMediaPC(t, h.offerNet, nil, mediaEngineConfig{video: true})
receiver := newMediaPC(t, h.answerNet, nil, mediaEngineConfig{video: true})
sender := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.OfferNet, MediaEngine: vnettest.MediaEngineConfig{Video: true}})
receiver := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.AnswerNet, MediaEngine: vnettest.MediaEngineConfig{Video: true}})
track, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
@@ -407,7 +248,7 @@ func TestPionVNetForwardingSpike(t *testing.T) {
cap := captureTrack(receiver)
signalPair(t, sender, receiver)
vnettest.SignalPair(t, sender, receiver)
// pump a few RTP packets from the sender track
go func() {
@@ -444,12 +285,12 @@ func TestPionVNetForwardingSpike(t *testing.T) {
// contiguous sequence numbers, timestamps, payload bytes, and that the padding bit
// is cleared regardless of the source packet's padding bit.
func TestDownTrackForwardsMedia(t *testing.T) {
h := buildVNet(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, opusCodecParams, p, mediaEngineConfig{video: false})
dh := newBoundDownTrack(t, h, factory, opusCodecParams, p, vnettest.MediaEngineConfig{})
const numPackets = 20
sn := uint16(23333)
@@ -516,12 +357,12 @@ func TestDownTrackForwardsMedia(t *testing.T) {
// type, sequence number, timestamp, payload bytes, and that the padding bit is
// cleared regardless of the source packet's padding bit.
func TestDownTrackRetransmitsPacketsAsIs(t *testing.T) {
h := buildVNet(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, opusCodecParams, p, mediaEngineConfig{video: false})
dh := newBoundDownTrack(t, h, factory, opusCodecParams, p, vnettest.MediaEngineConfig{})
const numPackets = 10
targetSN := uint16(40000)
@@ -557,6 +398,7 @@ func TestDownTrackRetransmitsPacketsAsIs(t *testing.T) {
uint64(ts),
0,
raw,
0,
)
require.NoError(t, err)
targetSN++
@@ -595,13 +437,13 @@ func TestDownTrackRetransmitsPacketsAsIs(t *testing.T) {
// fields are not observable on the far side. The packets are still handed to the real
// pacer (and written over pion).
func TestDownTrackRetransmitsPacketsViaRTX(t *testing.T) {
h := buildVNet(t)
h := vnettest.NewHosts(t)
factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory()
cp := &capturingPacer{inner: pacer.NewPassThrough(logger.GetLogger(), newNullBWE())}
t.Cleanup(cp.Stop)
// VP8 registers an RTX codec, so the bound DownTrack has an RTX SSRC / payload type.
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, mediaEngineConfig{video: true})
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, vnettest.MediaEngineConfig{Video: true})
require.NotZero(t, dh.dt.SSRCRTX(), "RTX ssrc should be negotiated")
require.NotZero(t, dh.dt.PayloadTypeRTXForTest(), "RTX payload type should be negotiated")
@@ -635,7 +477,7 @@ func TestDownTrackRetransmitsPacketsViaRTX(t *testing.T) {
ts := uint32(700000 + i*3000)
wants[targetSN] = want{payload: payload, ts: ts}
_, err = dh.dt.RetransmitForTest(uint64(src.SequenceNumber), targetSN, ts, uint64(ts), 0, raw)
_, err = dh.dt.RetransmitForTest(uint64(src.SequenceNumber), targetSN, ts, uint64(ts), 0, raw, 0)
require.NoError(t, err)
targetSN++
}
@@ -673,6 +515,65 @@ func TestDownTrackRetransmitsPacketsViaRTX(t *testing.T) {
}
}
// TestDownTrackReplaysPacketTrailerStripOnRetransmit verifies that a packet whose
// packet trailer was stripped when it was forwarded is retransmitted with the same
// bytes removed, i. e. the retransmitted media payload is byte identical to the
// original transmission.
func TestDownTrackReplaysPacketTrailerStripOnRetransmit(t *testing.T) {
h := vnettest.NewHosts(t)
factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory()
cp := &capturingPacer{inner: pacer.NewPassThrough(logger.GetLogger(), newNullBWE())}
t.Cleanup(cp.Stop)
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, vnettest.MediaEngineConfig{Video: true})
require.NotZero(t, dh.dt.SSRCRTX(), "RTX ssrc should be negotiated")
video := distinctivePayload(11, 40)
trailer := lktsTrailer()
src := &rtp.Packet{
Header: rtp.Header{
Version: 2,
PayloadType: 96,
SequenceNumber: 3000,
Timestamp: 270000,
SSRC: 0x44444444,
},
Payload: append(append([]byte{}, video...), trailer...),
}
raw, err := src.Marshal()
require.NoError(t, err)
osn := uint16(60000)
_, err = dh.dt.RetransmitForTest(
uint64(src.SequenceNumber),
osn,
src.Timestamp,
uint64(src.Timestamp),
0,
raw,
uint8(len(trailer)),
)
require.NoError(t, err)
require.Eventually(t, func() bool {
return len(cp.rtxPackets()) >= 1
}, 5*time.Second, 20*time.Millisecond, "expected RTX packet to be emitted")
pr := cp.rtxPackets()[0]
require.EqualValues(t, osn, binary.BigEndian.Uint16(pr.payload[0:2]))
require.Equal(t, video, pr.payload[2:], "retransmitted payload must not carry the packet trailer")
}
// lktsTrailer builds a 15-byte LKTS packet trailer carrying a user timestamp TLV.
func lktsTrailer() []byte {
trailer := []byte{0x01 ^ 0xFF, 8 ^ 0xFF}
for i := 0; i < 8; i++ {
trailer = append(trailer, byte(i)^0xFF)
}
trailer = append(trailer, 15^0xFF)
return append(trailer, packettrailer.Magic[:]...)
}
// -----------------------------------------------------------------------------
// WritePaddingRTP: padding-only packets
// -----------------------------------------------------------------------------
@@ -688,12 +589,12 @@ func TestDownTrackRetransmitsPacketsViaRTX(t *testing.T) {
// and reports both the padding bit and the padding size, so the received payload
// length equals the declared padding size (RTPPaddingMaxPayloadSize).
func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) {
h := buildVNet(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, mediaEngineConfig{video: true})
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, p, vnettest.MediaEngineConfig{Video: true})
// force a valid target/current layer so the video forwarder will forward (test seam
// also used by forwarder_test.go's disable()).
@@ -910,7 +811,7 @@ func TestDownTrackSendsProbePackets(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := buildVNet(t)
h := vnettest.NewHosts(t)
factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory()
b := tc.makeBWE()
@@ -920,9 +821,9 @@ func TestDownTrackSendsProbePackets(t *testing.T) {
// header extensions (abs-send-time for remote BWE, transport-cc for send-side)
// are needed for the DownTrack to pick up an ext id, which WriteProbePackets
// requires.
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, mediaEngineConfig{
video: true,
headerExtensions: true,
dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, vnettest.MediaEngineConfig{
Video: true,
HeaderExtensions: true,
})
require.NotZero(t, dh.dt.SSRCRTX(), "RTX ssrc should be negotiated")
+6 -4
View File
@@ -56,13 +56,15 @@ func (d *DownTrack) RetransmitForTest(
extTimestamp uint64,
layer int8,
sourcePkt []byte,
trailerStripped uint8,
) (int, error) {
epm := extPacketMeta{
packetMeta: packetMeta{
sourceSeqNo: sourceSeqNo,
targetSeqNo: targetSeqNo,
timestamp: timestamp,
layer: layer,
sourceSeqNo: sourceSeqNo,
targetSeqNo: targetSeqNo,
timestamp: timestamp,
layer: layer,
trailerStripped: trailerStripped,
},
extSequenceNumber: uint64(targetSeqNo),
extTimestamp: extTimestamp,
+18
View File
@@ -23,6 +23,7 @@ import (
"time"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"go.uber.org/zap/zapcore"
@@ -202,6 +203,8 @@ type TranslationParams struct {
incomingHeaderSize int
codecBytes []byte
marker bool
// end of the svc spatial layer frame
isEndOfLayerFrame bool
}
// -------------------------------------------------------------------
@@ -2125,6 +2128,20 @@ func (f *Forwarder) getTranslationParamsAudio(extPkt *buffer.ExtPacket, layer in
return tp, err
}
// should be called with lock held
func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool {
if extPkt.DependencyDescriptor != nil {
return extPkt.DependencyDescriptor.Descriptor != nil && extPkt.DependencyDescriptor.Descriptor.LastPacketInFrame
}
if f.mime == mime.MimeTypeVP9 {
vp9, ok := extPkt.Payload.(codecs.VP9Packet)
return ok && vp9.E
}
return false
}
// should be called with lock held
func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer int32) (TranslationParams, error) {
tp := TranslationParams{}
@@ -2169,6 +2186,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in
tp.isSwitching = result.IsSwitching
tp.ddBytes = result.DependencyDescriptorExtension
tp.marker = result.RTPMarker
tp.isEndOfLayerFrame = f.isEndOfLayerFrame(extPkt)
starting, err := f.getTranslationParamsCommon(extPkt, layer, &tp)
tp.isStarting = starting
+34
View File
@@ -17,14 +17,17 @@ package sfu
import (
"testing"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"github.com/livekit/mediatransportutil/pkg/codec"
"github.com/livekit/protocol/codecs/mime"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"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/testutils"
)
@@ -2215,3 +2218,34 @@ func TestGetRefLayerRTPTimestampBounds(t *testing.T) {
require.Error(t, err) // unavailable sender report, not invalid layer
require.Contains(t, err.Error(), "unavailable")
}
// TestForwarderIsEndOfLayerFrame checks the end-of-layer-frame detection used to
// locate packet trailers, which VP9 SVC can carry at the end of any spatial layer
// frame and not just at the end of a picture.
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}}))
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.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},
}))
require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{
DependencyDescriptor: &buffer.ExtDependencyDescriptor{
Descriptor: &dd.DependencyDescriptor{LastPacketInFrame: false},
},
Payload: codecs.VP9Packet{E: true},
}))
require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{
DependencyDescriptor: &buffer.ExtDependencyDescriptor{},
}))
}
+68 -64
View File
@@ -21,6 +21,7 @@ import (
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/livekit-server/pkg/sfu/utils"
"github.com/livekit/protocol/logger"
)
@@ -31,6 +32,12 @@ const (
rtxProbeCount = 10
)
// StreamInfoProber installs a bounded probe that identifies a remote stream from the
// mid/rid/rsid header extensions of its packets. Implemented by buffer.Factory.
type StreamInfoProber interface {
SetStreamInfoProbe(ssrc uint32, probe *buffer.StreamInfoProbe) bool
}
type streamInfo struct {
mid string
rid string
@@ -40,22 +47,51 @@ type streamInfo struct {
type RTXInfoExtractorFactory struct {
onStreamFound func(*interceptor.StreamInfo)
onRTXPairFound func(repair, base uint32, rsid string)
prober StreamInfoProber
lock sync.Mutex
streams map[uint32]streamInfo
paired map[uint32]struct{}
logger logger.Logger
}
func NewRTXInfoExtractorFactory(
onStreamFound func(*interceptor.StreamInfo),
onRTXPairFound func(repair, base uint32, rsid string),
prober StreamInfoProber,
simTracks map[uint32]SimulcastTrackInfo,
logger logger.Logger,
) *RTXInfoExtractorFactory {
return &RTXInfoExtractorFactory{
f := &RTXInfoExtractorFactory{
onStreamFound: onStreamFound,
onRTXPairFound: onRTXPairFound,
prober: prober,
streams: make(map[uint32]streamInfo),
paired: make(map[uint32]struct{}),
logger: logger,
}
f.seedSimulcastTracks(simTracks)
return f
}
// seedSimulcastTracks pairs migrated streams from the migration info. A migrated client
// is mid-stream and stops sending rid/rsid, so the extensions never appear on the wire
// and the pairing has to come from what is already known about the tracks.
func (f *RTXInfoExtractorFactory) seedSimulcastTracks(simTracks map[uint32]SimulcastTrackInfo) {
for ssrc, info := range simTracks {
if info.Mid == "" || info.StreamID == "" {
continue
}
if info.IsRepairStream {
f.SetStreamInfo(ssrc, info.Mid, "", info.StreamID)
continue
}
f.SetStreamInfo(ssrc, info.Mid, info.StreamID, "")
if info.RepairSSRC != 0 {
f.SetStreamInfo(info.RepairSSRC, info.Mid, "", info.StreamID)
}
}
}
func (f *RTXInfoExtractorFactory) NewInterceptor(id string) (interceptor.Interceptor, error) {
@@ -75,6 +111,12 @@ func (f *RTXInfoExtractorFactory) SetStreamInfo(ssrc uint32, mid, rid, rsid stri
return
}
// the same stream can be reported by both the packet probe and the migration info
if _, ok := f.paired[ssrc]; ok {
f.lock.Unlock()
return
}
if rsid != "" {
// repair stream found, find base stream
for base, info := range f.streams {
@@ -106,13 +148,15 @@ func (f *RTXInfoExtractorFactory) SetStreamInfo(ssrc uint32, mid, rid, rsid stri
rid: rid,
rsid: rsid,
}
f.lock.Unlock()
return
}
f.paired[repairSsrc] = struct{}{}
f.paired[baseSsrc] = struct{}{}
f.lock.Unlock()
if repairSsrc != 0 && baseSsrc != 0 {
f.onRTXPairFound(repairSsrc, baseSsrc, repairSid)
}
f.onRTXPairFound(repairSsrc, baseSsrc, repairSid)
}
// ------------------------------------------
@@ -134,65 +178,25 @@ func (u *RTXInfoExtractor) BindRemoteStream(info *interceptor.StreamInfo, reader
return reader
}
return &rtxInfoReader{
tryTimes: rtxProbeCount,
reader: reader,
midExtID: uint8(midExtensionID),
ridExtID: uint8(streamIDExtensionID),
rsidExtID: uint8(repairStreamIDExtensionID),
factory: u.factory,
logger: u.logger,
// Probe on the buffer write path rather than by wrapping this reader. Remote streams
// are consumed through SettingEngine.BufferFactory, so nothing here reads the
// interceptor chain. pion used to drive the repair stream reader, but since
// pion/webrtc#3470 it only does so when the application reads the TrackRemote or
// when no BufferFactory is set, neither of which holds.
ok := u.factory.prober.SetStreamInfoProbe(info.SSRC, &buffer.StreamInfoProbe{
MidExtID: uint8(midExtensionID),
RidExtID: uint8(streamIDExtensionID),
RsidExtID: uint8(repairStreamIDExtensionID),
Tries: rtxProbeCount,
OnFound: u.factory.SetStreamInfo,
})
if !ok {
u.logger.Warnw(
"could not install stream info probe, rtx pairing will not work", nil,
"ssrc", info.SSRC,
"mime", info.MimeType,
)
}
}
// ------------------------------------------
type rtxInfoReader struct {
tryTimes int
reader interceptor.RTPReader
midExtID uint8
ridExtID uint8
rsidExtID uint8
factory *RTXInfoExtractorFactory
logger logger.Logger
}
func (r *rtxInfoReader) Read(b []byte, a interceptor.Attributes) (int, interceptor.Attributes, error) {
n, a, err := r.reader.Read(b, a)
if r.tryTimes < 0 || err != nil {
return n, a, err
}
if a == nil {
a = make(interceptor.Attributes)
}
header, err := a.GetRTPHeader(b[:n])
if err != nil {
return n, a, nil
}
var mid, rid, rsid string
if payload := header.GetExtension(r.midExtID); payload != nil {
mid = string(payload)
}
if payload := header.GetExtension(r.ridExtID); payload != nil {
rid = string(payload)
}
if payload := header.GetExtension(r.rsidExtID); payload != nil {
rsid = string(payload)
}
if mid != "" && (rid != "" || rsid != "") {
r.logger.Debugw("stream found", "mid", mid, "rid", rid, "rsid", rsid, "ssrc", header.SSRC)
r.tryTimes = -1
go r.factory.SetStreamInfo(header.SSRC, mid, rid, rsid)
} else {
// ignore padding only packet for probe count
if !header.Padding || n-header.MarshalSize()-int(b[n-1]) != 0 {
r.tryTimes--
}
}
return n, a, nil
return reader
}
+5 -3
View File
@@ -25,9 +25,11 @@ const (
// StripTrailer returns the number of bytes to strip from the end of an RTP
// payload if it contains an LKTS trailer. The trailer is located by checking
// for the "LKTS" magic suffix and then reading the XORed trailer_len byte
// immediately before it. Returns 0 if absent or ineligible.
func StripTrailer(payload []byte, marker bool) int {
if !marker || len(payload) < envelopeSize {
// immediately before it. isEndOfFrame must be set only for packets ending an
// encoded frame, i. e. where a trailer could have been appended. Returns 0 if
// absent or ineligible.
func StripTrailer(payload []byte, isEndOfFrame bool) int {
if !isEndOfFrame || len(payload) < envelopeSize {
return 0
}
+1 -1
View File
@@ -948,7 +948,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
}
+7
View File
@@ -66,6 +66,8 @@ type packetMeta struct {
ddBytesSlice []byte
// abs-capture-time of packet
actBytes []byte
// number of packet trailer bytes stripped when the packet was forwarded
trailerStripped uint8
}
func (pm packetMeta) MarshalLogObject(e zapcore.ObjectEncoder) error {
@@ -87,6 +89,9 @@ func (pm packetMeta) MarshalLogObject(e zapcore.ObjectEncoder) error {
if len(pm.actBytes) != 0 {
e.AddInt("actBytes", len(pm.actBytes))
}
if pm.trailerStripped != 0 {
e.AddUint8("trailerStripped", pm.trailerStripped)
}
return nil
}
@@ -178,6 +183,7 @@ func (s *sequencer) push(
numCodecBytesIn int,
ddBytes []byte,
actBytes []byte,
trailerStripped int,
) {
s.Lock()
defer s.Unlock()
@@ -246,6 +252,7 @@ func (s *sequencer) push(
marker: marker,
layer: layer,
numCodecBytesIn: uint8(numCodecBytesIn),
trailerStripped: uint8(trailerStripped),
lastNack: s.getRefTime(packetTime), // delay retransmissions after the original transmission
}
pm := &s.meta[slot]
+12 -7
View File
@@ -29,11 +29,11 @@ func Test_sequencer(t *testing.T) {
off := uint16(15)
for i := uint64(1); i < 518; i++ {
seq.push(time.Now().UnixNano(), i, i+uint64(off), 123, true, 2, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), i, i+uint64(off), 123, true, 2, nil, 0, nil, nil, 0)
}
// send the last two out-of-order
seq.push(time.Now().UnixNano(), 519, 519+uint64(off), 123, false, 2, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), 518, 518+uint64(off), 123, true, 2, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), 519, 519+uint64(off), 123, false, 2, nil, 0, nil, nil, 0)
seq.push(time.Now().UnixNano(), 518, 518+uint64(off), 123, true, 2, nil, 0, nil, nil, 0)
req := []uint16{57, 58, 62, 63, 513, 514, 515, 516, 517}
res := seq.getExtPacketMetas(req)
@@ -63,14 +63,14 @@ func Test_sequencer(t *testing.T) {
require.Equal(t, val.extTimestamp, uint64(123))
}
seq.push(time.Now().UnixNano(), 521, 521+uint64(off), 123, true, 1, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), 521, 521+uint64(off), 123, true, 1, nil, 0, nil, nil, 0)
m := seq.getExtPacketMetas([]uint16{521 + off})
require.Equal(t, 0, len(m))
time.Sleep((ignoreRetransmission + 10) * time.Millisecond)
m = seq.getExtPacketMetas([]uint16{521 + off})
require.Equal(t, 1, len(m))
seq.push(time.Now().UnixNano(), 505, 505+uint64(off), 123, false, 1, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), 505, 505+uint64(off), 123, false, 1, nil, 0, nil, nil, 0)
m = seq.getExtPacketMetas([]uint16{505 + off})
require.Equal(t, 0, len(m))
time.Sleep((ignoreRetransmission + 10) * time.Millisecond)
@@ -83,7 +83,7 @@ func Test_sequencer_flush(t *testing.T) {
off := uint16(15)
for i := uint64(1); i < 100; i++ {
seq.push(time.Now().UnixNano(), i, i+uint64(off), 123, true, 2, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), i, i+uint64(off), 123, true, 2, nil, 0, nil, nil, 0)
}
preFlush := []uint16{57 + off, 58 + off}
@@ -96,7 +96,7 @@ func Test_sequencer_flush(t *testing.T) {
// the sequencer re-initializes on the next push and works normally for new packets
for i := uint64(200); i < 210; i++ {
seq.push(time.Now().UnixNano(), i, i+uint64(off), 456, true, 3, nil, 0, nil, nil)
seq.push(time.Now().UnixNano(), i, i+uint64(off), 456, true, 3, nil, 0, nil, nil, 0)
}
postFlush := []uint16{205 + off}
require.Equal(t, 0, len(seq.getExtPacketMetas(postFlush))) // not enough time elapsed yet
@@ -200,6 +200,7 @@ func Test_sequencer_getNACKSeqNo_exclusion(t *testing.T) {
len(tt.fields.codecBytesOversized),
tt.fields.ddBytesOversized,
tt.fields.actBytesOdd,
0,
)
} else {
if i.seqNo%2 == 0 {
@@ -214,6 +215,7 @@ func Test_sequencer_getNACKSeqNo_exclusion(t *testing.T) {
tt.fields.numCodecBytesInEven,
tt.fields.ddBytesEven,
tt.fields.actBytesEven,
0,
)
} else {
n.push(
@@ -227,6 +229,7 @@ func Test_sequencer_getNACKSeqNo_exclusion(t *testing.T) {
tt.fields.numCodecBytesInOdd,
tt.fields.ddBytesOdd,
tt.fields.actBytesOdd,
0,
)
}
}
@@ -354,6 +357,7 @@ func Test_sequencer_getNACKSeqNo_no_exclusion(t *testing.T) {
tt.fields.numCodecBytesInEven,
tt.fields.ddBytesEven,
tt.fields.actBytesEven,
0,
)
} else {
n.push(
@@ -367,6 +371,7 @@ func Test_sequencer_getNACKSeqNo_no_exclusion(t *testing.T) {
tt.fields.numCodecBytesInOdd,
tt.fields.ddBytesOdd,
tt.fields.actBytesOdd,
0,
)
}
}
+22 -7
View File
@@ -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,
})
})
}
@@ -182,6 +184,18 @@ func (t *telemetryService) ParticipantResumed(
})
}
// RoomIDChanged re-keys the room's stats workers.
//
// NOTE: this shares the queue with the stats and participant events it races with, so
// ops raised before the id changed (carrying `prevRoomID`) are applied before the
// re-key and ops raised after it (carrying the new id) are applied after. Callers
// should raise this as soon as the room starts reporting the new id.
func (t *telemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) {
t.enqueue(func() {
t.reKeyRoom(prevRoomID, livekit.RoomID(room.Sid), livekit.RoomName(room.Name))
})
}
func (t *telemetryService) ParticipantLeft(ctx context.Context,
room *livekit.Room,
participant *livekit.ParticipantInfo,
@@ -202,6 +216,7 @@ func (t *telemetryService) ParticipantLeft(ctx context.Context,
"participant", participant.Identity,
"participantID", participant.Sid,
"worker", worker,
"guard", guard,
)
}
}
+15
View File
@@ -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)
}
+1 -32
View File
@@ -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
}
-4
View File
@@ -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
}
+127
View File
@@ -626,6 +626,133 @@ func Test_BothDownstreamAndUpstreamStatsAreSentTogether(t *testing.T) {
require.Equal(t, livekit.StreamType_DOWNSTREAM, stats[1].Kind)
}
func Test_RoomIDChangeReKeysStatsWorkers(t *testing.T) {
fixture := createFixture()
// prepare
room := &livekit.Room{Sid: "RoomSid", Name: "RoomName"}
partSID := livekit.ParticipantID("part1")
participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)}
trackID := livekit.TrackID("trackID")
guard := &telemetry.ReferenceGuard{}
fixture.sut.ParticipantJoined(context.Background(), room, participantInfo, nil, nil, true, guard)
stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}}
fixture.sut.TrackStats(livekit.RoomID(room.Sid), livekit.RoomName(room.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1)
// do - the room restarts and gets a new id
restartedRoom := &livekit.Room{Sid: "RestartedSid", Name: "RoomName"}
fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(room.Sid), restartedRoom)
// stats reported with the new id reach the same worker
stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}}
fixture.sut.TrackStats(livekit.RoomID(restartedRoom.Sid), livekit.RoomName(restartedRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2)
fixture.flush()
// one worker, one flush, but two stats - each attributed to the session it was collected in
require.Equal(t, 1, fixture.analytics.SendStatsCallCount())
_, stats := fixture.analytics.SendStatsArgsForCall(0)
require.Equal(t, 2, len(stats))
byRoom := map[string]*livekit.AnalyticsStat{}
for _, stat := range stats {
require.Equal(t, string(partSID), stat.ParticipantId)
byRoom[stat.RoomId] = stat
}
require.Len(t, byRoom, 2)
require.Equal(t, uint64(33), byRoom[room.Sid].Streams[0].PrimaryBytes)
require.Equal(t, uint64(44), byRoom[restartedRoom.Sid].Streams[0].PrimaryBytes)
// the worker moved rather than being duplicated, so closing it out drains everything
fixture.sut.ParticipantLeft(context.Background(), restartedRoom, participantInfo, true, guard)
fixture.flush()
require.Equal(t, 1, fixture.analytics.SendStatsCallCount())
}
// a forwarded participant is in more than one room at a time under the same participant
// id, so re-keying one of those rooms must leave the other alone
func Test_RoomIDChangeLeavesForwardedParticipantAlone(t *testing.T) {
fixture := createFixture()
// prepare - the same participant id in a source room and a forwarding destination room
sourceRoom := &livekit.Room{Sid: "SourceSid", Name: "SourceRoom"}
destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"}
partSID := livekit.ParticipantID("part1")
participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)}
trackID := livekit.TrackID("trackID")
fixture.sut.ParticipantJoined(context.Background(), sourceRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{})
fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{})
// do - only the destination room restarts
restartedDest := &livekit.Room{Sid: "RestartedDestSid", Name: "DestRoom"}
fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(destRoom.Sid), restartedDest)
stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}}
fixture.sut.TrackStats(livekit.RoomID(sourceRoom.Sid), livekit.RoomName(sourceRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1)
stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}}
fixture.sut.TrackStats(livekit.RoomID(restartedDest.Sid), livekit.RoomName(restartedDest.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2)
fixture.flush()
// the source room's worker is untouched, the destination room's worker moved
byRoom := map[string]*livekit.AnalyticsStat{}
for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ {
_, stats := fixture.analytics.SendStatsArgsForCall(i)
for _, stat := range stats {
byRoom[stat.RoomId] = stat
}
}
require.Len(t, byRoom, 2)
require.Equal(t, uint64(33), byRoom[sourceRoom.Sid].Streams[0].PrimaryBytes)
require.Equal(t, sourceRoom.Name, byRoom[sourceRoom.Sid].RoomName)
require.Equal(t, uint64(44), byRoom[restartedDest.Sid].Streams[0].PrimaryBytes)
require.Equal(t, restartedDest.Name, byRoom[restartedDest.Sid].RoomName)
}
// a re-key should never land on a room that already has workers, but if it does only
// one worker can be keyed at (room, participant) and the superseded one must not be
// left unreachable in the flush list
func Test_RoomIDChangeParticipantCollision(t *testing.T) {
fixture := createFixture()
// prepare - the same participant id in the room being re-keyed and in its destination
prevRoom := &livekit.Room{Sid: "PrevSid", Name: "PrevRoom"}
destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"}
partSID := livekit.ParticipantID("part1")
participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)}
trackID := livekit.TrackID("trackID")
fixture.sut.ParticipantJoined(context.Background(), prevRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{})
fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{})
stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}}
fixture.sut.TrackStats(livekit.RoomID(prevRoom.Sid), livekit.RoomName(prevRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1)
// do
fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(prevRoom.Sid), destRoom)
// the superseded worker drains what it collected under the room it was in
fixture.flush()
byRoom := map[string]*livekit.AnalyticsStat{}
for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ {
_, stats := fixture.analytics.SendStatsArgsForCall(i)
for _, stat := range stats {
byRoom[stat.RoomId] = stat
}
}
require.Equal(t, uint64(33), byRoom[prevRoom.Sid].Streams[0].PrimaryBytes)
// the worker already keyed at the destination wins and keeps receiving stats
stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}}
fixture.sut.TrackStats(livekit.RoomID(destRoom.Sid), livekit.RoomName(destRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2)
fixture.flush()
_, stats := fixture.analytics.SendStatsArgsForCall(fixture.analytics.SendStatsCallCount() - 1)
require.Equal(t, 1, len(stats))
require.Equal(t, destRoom.Sid, stats[0].RoomId)
require.Equal(t, uint64(44), stats[0].Streams[0].PrimaryBytes)
}
func (f *telemetryServiceFixture) flush() {
time.Sleep(time.Millisecond * 500)
f.sut.FlushStats()
+151 -16
View File
@@ -18,6 +18,7 @@ import (
"context"
"sync"
"time"
"unsafe"
"go.uber.org/zap/zapcore"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -32,6 +33,17 @@ 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
}
@@ -52,6 +64,18 @@ func (s *ReferenceCount) Release(guard *ReferenceGuard) bool {
return s.count == 0
}
// Take hands over every reference held, leaving none behind.
func (s *ReferenceCount) Take() int {
count := s.count
s.count = 0
return count
}
// Absorb takes on references handed over from elsewhere.
func (s *ReferenceCount) Absorb(count int) {
s.count += count
}
func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error {
e.AddInt("count", s.count)
return nil
@@ -59,19 +83,39 @@ func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error {
// ----------------------------------------
// statsBatch is stats collected while the worker was in one room
type statsBatch struct {
roomID livekit.RoomID
roomName livekit.RoomName
incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat
outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat
}
func (b statsBatch) isEmpty() bool {
return len(b.incomingPerTrack) == 0 && len(b.outgoingPerTrack) == 0
}
// ----------------------------------------
// StatsWorker handles participant stats
type StatsWorker struct {
next *StatsWorker
ctx context.Context
t TelemetryService
roomID livekit.RoomID
roomName livekit.RoomName
participantID livekit.ParticipantID
participantIdentity livekit.ParticipantIdentity
isConnected bool
lock sync.RWMutex
lock sync.RWMutex
// the room a worker belongs to can change mid-session, so it is mutable state
// guarded by `lock`. it is kept in sync with the key the worker is filed under in
// telemetryService.workers, see telemetryService.reKeyRoom.
roomID livekit.RoomID
roomName livekit.RoomName
// batches sealed off by a room change, they carry the room they were collected
// in and go out on the next flush
sealed []statsBatch
isConnected bool
outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat
incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat
refCount ReferenceCount
@@ -115,7 +159,56 @@ func (s *StatsWorker) ParticipantID() livekit.ParticipantID {
return s.participantID
}
func (s *StatsWorker) RoomID() livekit.RoomID {
s.lock.RLock()
defer s.lock.RUnlock()
return s.roomID
}
// SetRoom re-points the worker at a room.
//
// Stats collected so far are sealed off rather than re-stamped - a room id changes
// because the previous session ended, so what was collected under it belongs to it.
// Sealing keeps the re-key free of any sending, the sealed stats go out on the next
// flush like every other stat.
func (s *StatsWorker) SetRoom(roomID livekit.RoomID, roomName livekit.RoomName) {
s.lock.Lock()
defer s.lock.Unlock()
if s.roomID == roomID && s.roomName == roomName {
return
}
if batch := s.sealStatsLocked(); !batch.isEmpty() {
s.sealed = append(s.sealed, batch)
}
s.roomID = roomID
s.roomName = roomName
}
// sealStatsLocked hands over everything collected since the last seal, stamped
// with the room it was collected in
func (s *StatsWorker) sealStatsLocked() statsBatch {
batch := statsBatch{
roomID: s.roomID,
roomName: s.roomName,
incomingPerTrack: s.incomingPerTrack,
outgoingPerTrack: s.outgoingPerTrack,
}
s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat)
s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat)
return batch
}
func (s *StatsWorker) SetConnected() {
if s == nil {
return
}
s.lock.Lock()
s.isConnected = true
s.lock.Unlock()
@@ -132,19 +225,24 @@ func (s *StatsWorker) Flush(now time.Time, closeWait time.Duration) bool {
ts := timestamppb.New(now)
s.lock.Lock()
stats := make([]*livekit.AnalyticsStat, 0, len(s.incomingPerTrack)+len(s.outgoingPerTrack))
incomingPerTrack := s.incomingPerTrack
s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat)
outgoingPerTrack := s.outgoingPerTrack
s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat)
// anything sealed off by a room change goes out along with the current batch,
// each stamped with the room it was collected in
batches := append(s.sealed, s.sealStatsLocked())
s.sealed = nil
closed := !s.closedAt.IsZero() && now.Sub(s.closedAt) > closeWait
s.lock.Unlock()
stats = s.collectStats(ts, livekit.StreamType_UPSTREAM, incomingPerTrack, stats)
stats = s.collectStats(ts, livekit.StreamType_DOWNSTREAM, outgoingPerTrack, stats)
numTracks := 0
for _, batch := range batches {
numTracks += len(batch.incomingPerTrack) + len(batch.outgoingPerTrack)
}
stats := make([]*livekit.AnalyticsStat, 0, numTracks)
for _, batch := range batches {
stats = s.collectStats(ts, batch, livekit.StreamType_UPSTREAM, stats)
stats = s.collectStats(ts, batch, livekit.StreamType_DOWNSTREAM, stats)
}
if len(stats) > 0 {
s.t.SendStats(s.ctx, stats)
}
@@ -167,6 +265,34 @@ func (s *StatsWorker) Close(guard *ReferenceGuard) bool {
return ok
}
// ForceClose closes the worker irrespective of outstanding references. Used when a
// worker can no longer be reached through the worker map, so that it drains and is
// reaped instead of lingering in the flush list forever.
//
// Its references are handed over to `successor`, the worker that can be reached in its
// place, so that whoever holds one still has a live worker to close. A ReferenceGuard
// records that it activated some worker, not which one, so leaving them behind would
// strand the successor with references it can never see released.
func (s *StatsWorker) ForceClose(successor *StatsWorker) bool {
s.lock.Lock()
if !s.closedAt.IsZero() {
s.lock.Unlock()
return false
}
s.closedAt = time.Now()
count := s.refCount.Take()
s.lock.Unlock()
if successor != nil && count != 0 {
successor.lock.Lock()
successor.refCount.Absorb(count)
successor.lock.Unlock()
}
return true
}
func (s *StatsWorker) Closed(guard *ReferenceGuard) bool {
s.lock.Lock()
defer s.lock.Unlock()
@@ -179,10 +305,15 @@ func (s *StatsWorker) Closed(guard *ReferenceGuard) bool {
func (s *StatsWorker) collectStats(
ts *timestamppb.Timestamp,
batch statsBatch,
streamType livekit.StreamType,
perTrack map[livekit.TrackID][]*livekit.AnalyticsStat,
stats []*livekit.AnalyticsStat,
) []*livekit.AnalyticsStat {
perTrack := batch.incomingPerTrack
if streamType == livekit.StreamType_DOWNSTREAM {
perTrack = batch.outgoingPerTrack
}
for trackID, analyticsStats := range perTrack {
coalesced := coalesce(analyticsStats)
if coalesced == nil {
@@ -192,15 +323,19 @@ func (s *StatsWorker) collectStats(
coalesced.TimeStamp = ts
coalesced.TrackId = string(trackID)
coalesced.Kind = streamType
coalesced.RoomId = string(s.roomID)
coalesced.RoomId = string(batch.roomID)
coalesced.ParticipantId = string(s.participantID)
coalesced.RoomName = string(s.roomName)
coalesced.RoomName = string(batch.roomName)
stats = append(stats, coalesced)
}
return stats
}
func (s *StatsWorker) MarshalLogObject(e zapcore.ObjectEncoder) error {
if s == nil {
return nil
}
s.lock.RLock()
defer s.lock.RUnlock()
+83
View File
@@ -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,83 @@ func TestStatsWorker(t *testing.T) {
require.True(t, w.Close(&g1))
require.True(t, w.Closed(&g1))
})
// a ReferenceGuard records that it activated some worker, not which one, so a
// superseded worker has to hand its references to the one reachable in its place
t.Run("force close hands references to the successor", func(t *testing.T) {
t.Run("a guard shared by both workers", func(t *testing.T) {
// the second worker never got a reference, the guard was already activated
var g ReferenceGuard
superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g)
survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &g)
require.Equal(t, 1, superseded.refCount.count)
require.Equal(t, 0, survivor.refCount.count)
require.True(t, superseded.ForceClose(survivor))
require.Equal(t, 0, superseded.refCount.count)
require.Equal(t, 1, survivor.refCount.count)
// without the hand over this would leave the survivor at -1 and never closed
require.True(t, survivor.Close(&g))
require.True(t, survivor.Closed(&g))
})
t.Run("a guard per worker", func(t *testing.T) {
var gSuperseded, gSurvivor ReferenceGuard
superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &gSuperseded)
survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &gSurvivor)
require.True(t, superseded.ForceClose(survivor))
require.Equal(t, 2, survivor.refCount.count)
// the superseded worker's owner departs, it must not close the survivor early
require.False(t, survivor.Close(&gSuperseded))
require.True(t, survivor.Close(&gSurvivor))
})
t.Run("closing an already closed worker holds on to its references", func(t *testing.T) {
var g ReferenceGuard
superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g)
survivor := newStatsWorker(t.Context(), nil, "", "", "", "", nil)
require.True(t, superseded.ForceClose(nil))
require.False(t, superseded.ForceClose(survivor))
require.Equal(t, 0, survivor.refCount.count)
})
})
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[livekit.RoomID]map[livekit.ParticipantID]*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[roomID][pID])
})
t.Run("closed worker already reaped", func(t *testing.T) {
delete(ts.workers[roomID], pID)
late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g)
require.True(t, found)
require.Nil(t, late)
require.Empty(t, ts.workers[roomID])
late.SetConnected()
})
}
@@ -129,11 +129,19 @@ 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
}
RoomIDChangedStub func(context.Context, livekit.RoomID, *livekit.Room)
roomIDChangedMutex sync.RWMutex
roomIDChangedArgsForCall []struct {
arg1 context.Context
arg2 livekit.RoomID
arg3 *livekit.Room
}
RoomProjectReporterStub func(context.Context) roomobs.ProjectReporter
roomProjectReporterMutex sync.RWMutex
@@ -880,17 +888,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 +909,51 @@ 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) RoomIDChanged(arg1 context.Context, arg2 livekit.RoomID, arg3 *livekit.Room) {
fake.roomIDChangedMutex.Lock()
fake.roomIDChangedArgsForCall = append(fake.roomIDChangedArgsForCall, struct {
arg1 context.Context
arg2 livekit.RoomID
arg3 *livekit.Room
}{arg1, arg2, arg3})
stub := fake.RoomIDChangedStub
fake.recordInvocation("RoomIDChanged", []interface{}{arg1, arg2, arg3})
fake.roomIDChangedMutex.Unlock()
if stub != nil {
fake.RoomIDChangedStub(arg1, arg2, arg3)
}
}
func (fake *FakeTelemetryService) RoomIDChangedCallCount() int {
fake.roomIDChangedMutex.RLock()
defer fake.roomIDChangedMutex.RUnlock()
return len(fake.roomIDChangedArgsForCall)
}
func (fake *FakeTelemetryService) RoomIDChangedCalls(stub func(context.Context, livekit.RoomID, *livekit.Room)) {
fake.roomIDChangedMutex.Lock()
defer fake.roomIDChangedMutex.Unlock()
fake.RoomIDChangedStub = stub
}
func (fake *FakeTelemetryService) RoomIDChangedArgsForCall(i int) (context.Context, livekit.RoomID, *livekit.Room) {
fake.roomIDChangedMutex.RLock()
defer fake.roomIDChangedMutex.RUnlock()
argsForCall := fake.roomIDChangedArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
}
func (fake *FakeTelemetryService) RoomProjectReporter(arg1 context.Context) roomobs.ProjectReporter {
+132 -16
View File
@@ -19,6 +19,7 @@ import (
"sync"
"time"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/livekit-server/pkg/utils"
"github.com/livekit/protocol/codecs/mime"
"github.com/livekit/protocol/livekit"
@@ -35,7 +36,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)
@@ -45,6 +46,9 @@ type TelemetryService interface {
ParticipantResumed(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, nodeID livekit.NodeID, reason livekit.ReconnectReason)
// ParticipantLeft - the participant leaves the room, only sent if ParticipantActive has been called before
ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard)
// RoomIDChanged - the room kept its session, but got a different id (a provisional room id
// replaced by the resolved one), re-keys the stats workers of every participant in the room
RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room)
// TrackPublishRequested - a publication attempt has been received
TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool)
// TrackPublished - a publication attempt has been successful
@@ -105,7 +109,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) {
@@ -114,6 +119,8 @@ func (n NullTelemetryService) ParticipantResumed(ctx context.Context, room *live
}
func (n NullTelemetryService) ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard) {
}
func (n NullTelemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) {
}
func (n NullTelemetryService) TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) {
}
func (n NullTelemetryService) TrackPublished(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) {
@@ -165,11 +172,6 @@ const (
telemetryStatsUpdateInterval = time.Second * 30
)
type statsWorkerKey struct {
roomID livekit.RoomID
participantID livekit.ParticipantID
}
type telemetryService struct {
AnalyticsService
@@ -177,7 +179,7 @@ type telemetryService struct {
jobsQueue *utils.OpsQueue
workersMu sync.RWMutex
workers map[statsWorkerKey]*StatsWorker
workers map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker
workerList *StatsWorker
flushMu sync.Mutex
@@ -193,7 +195,7 @@ func NewTelemetryService(notifier webhook.QueuedNotifier, analytics AnalyticsSer
FlushOnStop: true,
Logger: logger.GetLogger(),
}),
workers: make(map[statsWorkerKey]*StatsWorker),
workers: make(map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker),
}
t.jobsQueue.Start()
@@ -242,9 +244,12 @@ func (t *telemetryService) FlushStats() {
if reap != nil {
t.workersMu.Lock()
for reap != nil {
key := statsWorkerKey{reap.roomID, reap.participantID}
if reap == t.workers[key] {
delete(t.workers, key)
roomID := reap.RoomID()
if roomWorkers := t.workers[roomID]; reap == roomWorkers[reap.participantID] {
delete(roomWorkers, reap.participantID)
if len(roomWorkers) == 0 {
delete(t.workers, roomID)
}
}
reap = reap.next
}
@@ -266,7 +271,7 @@ func (t *telemetryService) getWorker(roomID livekit.RoomID, participantID liveki
t.workersMu.RLock()
defer t.workersMu.RUnlock()
worker, ok = t.workers[statsWorkerKey{roomID, participantID}]
worker, ok = t.workers[roomID][participantID]
return
}
@@ -281,17 +286,50 @@ func (t *telemetryService) getOrCreateWorker(
t.workersMu.Lock()
defer t.workersMu.Unlock()
key := statsWorkerKey{roomID, participantID}
worker, ok := t.workers[key]
if roomID == "" {
logger.Warnw(
"telemetry stats worker keyed under an empty room id", nil,
"room", roomName,
"participant", participantIdentity,
"participantID", participantID,
"guard", guard,
)
}
roomWorkers := t.workers[roomID]
worker, ok := roomWorkers[participantID]
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,
@@ -305,7 +343,11 @@ func (t *telemetryService) getOrCreateWorker(
worker.SetConnected()
}
t.workers[key] = worker
if roomWorkers == nil {
roomWorkers = make(map[livekit.ParticipantID]*StatsWorker)
t.workers[roomID] = roomWorkers
}
roomWorkers[participantID] = worker
worker.next = t.workerList
t.workerList = worker
@@ -313,6 +355,80 @@ func (t *telemetryService) getOrCreateWorker(
return worker, false
}
// reKeyRoom files every one of a room's stats workers under `roomID` instead of
// `prevRoomID`.
//
// A room can be restarted while participants are connected and reporting stats, which
// gives it a new id. As every worker of the room moves at once, the move is a single map
// splice - the workers themselves are untouched and keep their place in the flush list.
// Each worker then seals off what it collected under `prevRoomID` so those stats stay
// attributed to the session that ended (see StatsWorker.SetRoom).
func (t *telemetryService) reKeyRoom(prevRoomID livekit.RoomID, roomID livekit.RoomID, roomName livekit.RoomName) {
if prevRoomID == roomID {
return
}
t.workersMu.Lock()
defer t.workersMu.Unlock()
roomWorkers := t.workers[prevRoomID]
if len(roomWorkers) == 0 {
delete(t.workers, prevRoomID)
return
}
delete(t.workers, prevRoomID)
existing := t.workers[roomID]
if existing == nil {
t.workers[roomID] = roomWorkers
} else {
// should not happen as a room id is only ever replaced by a freshly minted one
logger.Warnw(
"telemetry re-keying room into an existing entry", nil,
"prevRoomID", prevRoomID,
"room", roomName,
"roomID", roomID,
"numWorkers", len(roomWorkers),
"numExistingWorkers", len(existing),
)
}
for participantID, worker := range roomWorkers {
if existing != nil {
if survivor, ok := existing[participantID]; ok {
// only one worker can be keyed at (room, participant) and the one already
// filed there wins, close the superseded one so that it drains and is
// reaped instead of lingering in the flush list unreachable
forceClosed := worker.ForceClose(survivor)
logger.Infow(
"telemetry force closing superseded stats worker",
"prevRoomID", prevRoomID,
"roomID", roomID,
"participantID", participantID,
"forceClosed", forceClosed,
"superseded", worker,
"survivor", survivor,
)
if forceClosed {
prometheus.SubParticipant()
}
continue
}
existing[participantID] = worker
}
worker.SetRoom(roomID, roomName)
}
logger.Infow(
"telemetry re-keyed room",
"prevRoomID", prevRoomID,
"room", roomName,
"roomID", roomID,
"numWorkers", len(roomWorkers),
)
}
func (t *telemetryService) LocalRoomState(ctx context.Context, info *livekit.AnalyticsNodeRooms) {
t.enqueue(func() {
t.SendNodeRoomStates(ctx, info)
+231
View File
@@ -0,0 +1,231 @@
// 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 vnettest sets up real pion peer connections on an in-memory virtual
// network, for integration tests that exercise media paths without a server.
//
// It depends only on pion, so it can be imported both by tests inside pkg/... and by
// the top level test package.
package vnettest
import (
"fmt"
"io"
"sync"
"testing"
"time"
"github.com/pion/interceptor"
"github.com/pion/logging"
"github.com/pion/sdp/v3"
"github.com/pion/transport/v4/packetio"
"github.com/pion/transport/v4/vnet"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
)
const (
VP8PayloadType = 96
RTXPayloadType = VP8PayloadType + 1
OpusPayloadType = 111
)
// Hosts are the two ends of a started virtual network.
type Hosts struct {
OfferNet *vnet.Net
AnswerNet *vnet.Net
}
// NewHosts returns two hosts on a started virtual network, torn down with the test.
func NewHosts(t *testing.T) *Hosts {
t.Helper()
wan, err := vnet.NewRouter(&vnet.RouterConfig{
CIDR: "1.2.3.0/24",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
require.NoError(t, err)
offerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.4"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(offerNet))
answerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.5"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(answerNet))
require.NoError(t, wan.Start())
t.Cleanup(func() { _ = wan.Stop() })
return &Hosts{OfferNet: offerNet, AnswerNet: answerNet}
}
// NewSettingEngine returns a setting engine bound to net, with ICE timeouts short
// enough to keep tests quick.
func NewSettingEngine(net *vnet.Net) webrtc.SettingEngine {
se := webrtc.SettingEngine{}
se.SetNet(net)
se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
se.SetICETimeouts(5*time.Second, 5*time.Second, 500*time.Millisecond)
return se
}
// MediaEngineConfig describes what to register on a media engine.
type MediaEngineConfig struct {
Video bool // VP8 and its RTX codec; otherwise opus
HeaderExtensions bool // abs-send-time + transport-cc
SimulcastExtensions bool // mid + rid + rsid
}
func VideoRTCPFeedback() []webrtc.RTCPFeedback {
return []webrtc.RTCPFeedback{
{Type: webrtc.TypeRTCPFBNACK},
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
{Type: webrtc.TypeRTCPFBTransportCC},
{Type: webrtc.TypeRTCPFBGoogREMB},
}
}
func NewMediaEngine(t *testing.T, cfg MediaEngineConfig) *webrtc.MediaEngine {
t.Helper()
me := &webrtc.MediaEngine{}
kind := webrtc.RTPCodecTypeAudio
if cfg.Video {
kind = webrtc.RTPCodecTypeVideo
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, RTCPFeedback: VideoRTCPFeedback(),
},
PayloadType: VP8PayloadType,
}, kind))
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeRTX,
ClockRate: 90000,
SDPFmtpLine: fmt.Sprintf("apt=%d", VP8PayloadType),
},
PayloadType: RTXPayloadType,
}, kind))
} else {
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2,
},
PayloadType: OpusPayloadType,
}, kind))
}
if cfg.HeaderExtensions {
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.ABSSendTimeURI}, kind))
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.TransportCCURI}, kind))
}
if cfg.SimulcastExtensions {
require.NoError(t, webrtc.ConfigureSimulcastExtensionHeaders(me))
}
return me
}
// PCConfig describes a peer connection on the virtual network.
type PCConfig struct {
Net *vnet.Net
MediaEngine MediaEngineConfig
// BufferFactory is SettingEngine.BufferFactory, e. g. buffer.Factory.GetOrNew.
// Optional.
BufferFactory func(packetType packetio.BufferPacketType, ssrc uint32) io.ReadWriteCloser
}
// NewPeerConnection builds a peer connection on the virtual network with no
// interceptors, so nothing rewrites what a test puts on the wire.
func NewPeerConnection(t *testing.T, cfg PCConfig) *webrtc.PeerConnection {
t.Helper()
se := NewSettingEngine(cfg.Net)
se.BufferFactory = cfg.BufferFactory
api := webrtc.NewAPI(
webrtc.WithMediaEngine(NewMediaEngine(t, cfg.MediaEngine)),
webrtc.WithSettingEngine(se),
webrtc.WithInterceptorRegistry(&interceptor.Registry{}),
)
pc, err := api.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
t.Cleanup(func() { _ = pc.Close() })
return pc
}
// GatheredOffer creates an offer and waits for gathering, so the SDP carries every
// candidate and the caller needs no trickle.
func GatheredOffer(t *testing.T, pc *webrtc.PeerConnection) webrtc.SessionDescription {
t.Helper()
offer, err := pc.CreateOffer(nil)
require.NoError(t, err)
gathered := webrtc.GatheringCompletePromise(pc)
require.NoError(t, pc.SetLocalDescription(offer))
<-gathered
return *pc.LocalDescription()
}
// SignalPair performs a full offer/answer exchange between two peer connections and
// waits for both to connect.
func SignalPair(t *testing.T, offerer, answerer *webrtc.PeerConnection) {
t.Helper()
connected := UntilConnected(offerer, answerer)
require.NoError(t, answerer.SetRemoteDescription(GatheredOffer(t, offerer)))
answer, err := answerer.CreateAnswer(nil)
require.NoError(t, err)
gathered := webrtc.GatheringCompletePromise(answerer)
require.NoError(t, answerer.SetLocalDescription(answer))
<-gathered
require.NoError(t, offerer.SetRemoteDescription(*answerer.LocalDescription()))
select {
case <-connected:
case <-time.After(30 * time.Second):
t.Fatal("timed out waiting for peer connections to connect")
}
}
// UntilConnected closes the returned channel once every peer connection is connected.
func UntilConnected(pcs ...*webrtc.PeerConnection) <-chan struct{} {
var wg sync.WaitGroup
wg.Add(len(pcs))
for _, pc := range pcs {
var once sync.Once
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateConnected {
once.Do(wg.Done)
}
})
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
return done
}
+122
View File
@@ -0,0 +1,122 @@
// 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 (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestChangeNotifier(t *testing.T) {
t.Run("Observer management", func(t *testing.T) {
notifier := NewChangeNotifier()
require.False(t, notifier.HasObservers())
called := false
notifier.AddObserver("test-key", func() {
called = true
})
require.True(t, notifier.HasObservers())
notifier.RemoveObserver("test-key")
require.False(t, notifier.HasObservers())
require.False(t, called)
})
t.Run("Notification triggers callbacks asynchronously", func(t *testing.T) {
notifier := NewChangeNotifier()
var wg sync.WaitGroup
wg.Add(2)
var mu sync.Mutex
callCounts := make(map[string]int)
notifier.AddObserver("obs1", func() {
mu.Lock()
callCounts["obs1"]++
mu.Unlock()
wg.Done()
})
notifier.AddObserver("obs2", func() {
mu.Lock()
callCounts["obs2"]++
mu.Unlock()
wg.Done()
})
notifier.NotifyChanged()
// Wait for async execution of observers
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Success
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for change notification callbacks")
}
mu.Lock()
require.Equal(t, 1, callCounts["obs1"])
require.Equal(t, 1, callCounts["obs2"])
mu.Unlock()
})
}
func TestChangeNotifierManager(t *testing.T) {
t.Run("Get and Create Notifiers", func(t *testing.T) {
manager := NewChangeNotifierManager()
require.Nil(t, manager.GetNotifier("non-existent"))
notifier := manager.GetOrCreateNotifier("room1")
require.NotNil(t, notifier)
retrieved := manager.GetNotifier("room1")
require.Equal(t, notifier, retrieved)
// GetOrCreate should return the existing one
again := manager.GetOrCreateNotifier("room1")
require.Equal(t, notifier, again)
})
t.Run("Remove Notifiers with HasObservers check", func(t *testing.T) {
manager := NewChangeNotifierManager()
_ = manager.GetOrCreateNotifier("room1")
// Case 1: notifier has no observers, should be removed
manager.RemoveNotifier("room1", false)
require.Nil(t, manager.GetNotifier("room1"))
// Re-create and add an observer
notifier := manager.GetOrCreateNotifier("room1")
notifier.AddObserver("observer", func() {})
// Case 2: notifier has observer, RemoveNotifier(..., false) should not remove it
manager.RemoveNotifier("room1", false)
require.NotNil(t, manager.GetNotifier("room1"))
// Case 3: notifier has observer, RemoveNotifier(..., true) (force) should remove it
manager.RemoveNotifier("room1", true)
require.Nil(t, manager.GetNotifier("room1"))
})
}
+30 -9
View File
@@ -14,24 +14,45 @@
package utils
import "slices"
import (
"cmp"
"slices"
)
// Median gets median value for an array
func Median[T float32](input []T) T {
// OrderedNumber defines a constraint for numeric types that can be ordered and divided.
type OrderedNumber interface {
cmp.Ordered
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~uintptr | ~float32 | ~float64
}
// Median gets the median value for a slice without modifying the original slice.
//
// Note:
// 1. For integer types, if the slice has an even length, the division (/ 2)
// is performed using integer division, which truncates the result towards zero.
// 2. Uses an overflow-safe formula left + (right-left)/2 to support narrow integer types.
func Median[T OrderedNumber](input []T) T {
num := len(input)
switch num {
case 0:
return 0
var zero T
return zero
case 1:
return input[0]
}
slices.Sort(input)
// Clone the slice to avoid mutating the caller's slice
sortedInput := slices.Clone(input)
slices.Sort(sortedInput)
if num%2 != 0 {
return input[num/2]
return sortedInput[num/2]
}
left := input[num/2-1]
right := input[num/2]
return (left + right) / 2
left := sortedInput[num/2-1]
right := sortedInput[num/2]
return left + (right-left)/T(2)
}
func Signum[T int | int8 | int16 | int32 | int64 | float32 | float64](val T) int {
+89
View File
@@ -0,0 +1,89 @@
// 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 (
"slices"
"testing"
"github.com/stretchr/testify/require"
)
func TestMedian(t *testing.T) {
t.Run("Empty slice", func(t *testing.T) {
require.Equal(t, float32(0), Median([]float32{}))
require.Equal(t, int(0), Median([]int{}))
})
t.Run("Single element", func(t *testing.T) {
require.Equal(t, float32(42), Median([]float32{42}))
require.Equal(t, int(42), Median([]int{42}))
})
t.Run("Odd length float32", func(t *testing.T) {
input := []float32{3.0, 1.0, 2.0}
require.Equal(t, float32(2.0), Median(input))
})
t.Run("Even length float32 - exact average", func(t *testing.T) {
input := []float32{1.0, 2.0, 3.0, 4.0}
require.Equal(t, float32(2.5), Median(input))
})
t.Run("Even length int - integer truncation", func(t *testing.T) {
input := []int{1, 2}
// (1 + 2) / 2 = 1.5 -> truncates to 1
require.Equal(t, int(1), Median(input))
inputOddAverage := []int{1, 3}
// (1 + 3) / 2 = 2
require.Equal(t, int(2), Median(inputOddAverage))
})
t.Run("Int8 overflow prevention", func(t *testing.T) {
// Without overflow protection: 120 + 126 = 246 (overflows int8 to -10) -> -10 / 2 = -5
// With overflow protection: 120 + (126-120)/2 = 123
input := []int8{120, 126}
require.Equal(t, int8(123), Median(input))
})
t.Run("Uint8 overflow prevention", func(t *testing.T) {
input := []uint8{250, 254}
require.Equal(t, uint8(252), Median(input))
})
t.Run("Immutability test - caller slice is not sorted/mutated", func(t *testing.T) {
original := []int{3, 1, 4, 2}
input := slices.Clone(original)
median := Median(input)
require.Equal(t, int(2), median)
require.Equal(t, original, input, "Input slice must not be modified by Median")
})
}
func TestSignum(t *testing.T) {
t.Run("Integer values", func(t *testing.T) {
require.Equal(t, -1, Signum(-42))
require.Equal(t, 0, Signum(0))
require.Equal(t, 1, Signum(42))
})
t.Run("Floating point values", func(t *testing.T) {
require.Equal(t, -1, Signum(float32(-0.01)))
require.Equal(t, 0, Signum(float32(0.0)))
require.Equal(t, 1, Signum(float32(0.01)))
})
}
+47
View File
@@ -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 utils
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDedupeSlice(t *testing.T) {
t.Run("Empty slice", func(t *testing.T) {
var input []int
result := DedupeSlice(input)
require.Empty(t, result)
})
t.Run("Single element", func(t *testing.T) {
input := []string{"hello"}
result := DedupeSlice(input)
require.Equal(t, []string{"hello"}, result)
})
t.Run("Unsorted slice with duplicates", func(t *testing.T) {
input := []int{4, 2, 4, 1, 3, 2}
result := DedupeSlice(input)
require.Equal(t, []int{1, 2, 3, 4}, result)
})
t.Run("Already sorted and unique", func(t *testing.T) {
input := []string{"apple", "banana", "cherry"}
result := DedupeSlice(input)
require.Equal(t, []string{"apple", "banana", "cherry"}, result)
})
}
+2 -1
View File
@@ -36,11 +36,12 @@
"groupName": "pion deps"
},
{
"description": "First-party deps, no need to quarantine new releases",
"description": "First-party deps, no need to quarantine new releases; they are co-released and depend on each other, so they are grouped into a single PR",
"matchManagers": ["gomod"],
"matchPackageNames": [
"github.com/livekit{/,}**"
],
"groupName": "livekit deps",
"minimumReleaseAge": null
},
{
+4 -4
View File
@@ -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
@@ -867,9 +867,9 @@ func (c *RTCClient) SetAttributes(attrs map[string]string) error {
func (c *RTCClient) hasPrimaryEverConnected() bool {
if c.subscriberAsPrimary.Load() {
return c.subscriber.HasEverConnected()
return c.subscriber.PeerConnectionHasEverConnected()
} else {
return c.publisher.HasEverConnected()
return c.publisher.PeerConnectionHasEverConnected()
}
}
@@ -1113,7 +1113,7 @@ func (c *RTCClient) ensurePublisherConnected() error {
return c.ctx.Err()
}
if c.publisher.HasEverConnected() {
if c.publisher.PeerConnectionHasEverConnected() {
return nil
}
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -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)
+70
View File
@@ -18,11 +18,14 @@ import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/pion/transport/v4/vnet"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/twitchtv/twirp"
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
@@ -33,9 +36,12 @@ import (
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/service"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/livekit-server/pkg/testutils"
"github.com/livekit/livekit-server/pkg/testutils/vnettest"
testclient "github.com/livekit/livekit-server/test/client"
)
@@ -386,3 +392,67 @@ func stopClients(clients ...*testclient.RTCClient) {
c.Stop()
}
}
// -----------------------------------------------------------------------------
// vnet media harness
//
// Setup specific to driving a real server transport over a virtual network. The
// pion side lives in pkg/testutils/vnettest, shared with the pkg/sfu media tests.
// -----------------------------------------------------------------------------
// newVNetWebRTCConfig builds the server side WebRTCConfig on net. The direction
// configs come from the production NewWebRTCConfig so the negotiated extensions and
// feedback stay in step with it; the setting engine is replaced so no real socket or
// ICE mux is bound.
func newVNetWebRTCConfig(t *testing.T, net *vnet.Net, bufferFactory *buffer.Factory) *rtc.WebRTCConfig {
t.Helper()
conf, err := config.NewConfig("", true, nil, nil)
require.NoError(t, err)
// an ephemeral port range instead of the dev mode single port, which would bind a mux
conf.RTC.TCPPort = 0
conf.RTC.UDPPort = rtcconfig.PortRange{}
conf.RTC.ICEPortRangeStart = 50000
conf.RTC.ICEPortRangeEnd = 60000
rtcConf, err := rtc.NewWebRTCConfig(conf)
require.NoError(t, err)
require.Nil(t, rtcConf.UDPMux, "test config must not bind a udp mux")
rtcConf.SettingEngine = vnettest.NewSettingEngine(net)
rtcConf.SetBufferFactory(bufferFactory)
return rtcConf
}
// stripDeclaredSSRCs removes the a=ssrc lines pion puts in its offer. Browsers doing
// rid based simulcast do not declare per-layer SSRCs, which is why a repair SSRC has to
// be learned at all; leaving them in would let the receiver resolve everything from SDP.
func stripDeclaredSSRCs(offer string) string {
lines := strings.Split(offer, "\r\n")
filtered := lines[:0]
for _, line := range lines {
if strings.HasPrefix(line, "a=ssrc") {
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\r\n")
}
// sendUntil calls send every 20ms until done reports true or the timeout expires,
// returning the final state of done.
func sendUntil(t *testing.T, timeout time.Duration, done func() bool, send func()) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if done() {
return true
}
send()
time.Sleep(20 * time.Millisecond)
}
return done()
}
+562
View File
@@ -0,0 +1,562 @@
// 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 test
// RTX repair stream pairing on simulcast (rid) streams, over a real PCTransport driven
// by a real pion publisher on a virtual network.
//
// RID based simulcast has no a=ssrc-group:FID line, so the pairing cannot come from
// SDP. The repair SSRC of a layer comes either from the mid/rsid header extensions on
// its packets, or - for a migrated publisher, which is mid-stream and no longer sends
// those extensions - from the migration info in TransportParams.SimTracks.
//
// Neither source fails loudly when it breaks: the repair buffer just accumulates
// packets that are never applied and NACK recovery for simulcast stops working. Both
// paths are therefore asserted end to end, by retransmitting a sequence number that is
// never sent on the primary stream and requiring it to surface on the primary buffer.
import (
"encoding/binary"
"fmt"
"sync"
"testing"
"time"
"github.com/pion/rtp"
"github.com/pion/sdp/v3"
"github.com/pion/transport/v4/vnet"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
sfuinterceptor "github.com/livekit/livekit-server/pkg/sfu/interceptor"
"github.com/livekit/livekit-server/pkg/testutils/vnettest"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
// the single video m-line of the publisher's offer
const rtxTestMid = "0"
const (
sendsExtensions = false
omitsExtensions = true
)
// SSRCs are chosen by the test rather than taken from the offer; see stripDeclaredSSRCs.
var rtxTestLayers = []struct {
rid string
ssrc uint32
rtxSSRC uint32
recoveredSeq uint16
}{
{rid: "q", ssrc: 1001, rtxSSRC: 2001, recoveredSeq: 50001},
{rid: "h", ssrc: 1002, rtxSSRC: 2002, recoveredSeq: 50002},
{rid: "f", ssrc: 1003, rtxSSRC: 2003, recoveredSeq: 50003},
}
// TestSimulcastRTXPairing covers a publisher sending mid/rid/rsid: the pairing comes
// from probing the packets.
func TestSimulcastRTXPairing(t *testing.T) {
h := newRTXHarness(t, nil)
h.run(t, sendsExtensions)
// pairing is also reported through the callback mediatrack subscribes to
require.Equal(t, len(rtxTestLayers), h.tracker.pairCount(), "not all rtx pairs found: %s", h.tracker.describe())
for _, w := range h.writers {
base, repair := h.tracker.pair(w.rid)
require.Equal(t, w.ssrc, base, "wrong base ssrc paired for rid %q", w.rid)
require.Equal(t, w.rtxSSRC, repair, "wrong repair ssrc paired for rid %q", w.rid)
}
}
// TestSimulcastRTXPairingAfterMigration covers a migrated publisher: it is mid-stream
// and sends no mid/rid/rsid, so UnhandleSimulcastInterceptor synthesises them for pion
// and the pairing has to come from SimTracks. RepairSSRC names the repair stream.
func TestSimulcastRTXPairingAfterMigration(t *testing.T) {
simTracks := make(map[uint32]sfuinterceptor.SimulcastTrackInfo, 2*len(rtxTestLayers))
for _, l := range rtxTestLayers {
simTracks[l.ssrc] = sfuinterceptor.SimulcastTrackInfo{
Mid: rtxTestMid,
StreamID: l.rid,
RepairSSRC: l.rtxSSRC,
}
simTracks[l.rtxSSRC] = sfuinterceptor.SimulcastTrackInfo{
Mid: rtxTestMid,
StreamID: l.rid,
IsRepairStream: true,
}
}
newRTXHarness(t, simTracks).run(t, omitsExtensions)
}
// TestSimulcastRTXPairingAfterMigrationWithoutRepairSSRC covers migration info that
// marks the repair stream but leaves RepairSSRC unset on the primary entry.
func TestSimulcastRTXPairingAfterMigrationWithoutRepairSSRC(t *testing.T) {
simTracks := make(map[uint32]sfuinterceptor.SimulcastTrackInfo, 2*len(rtxTestLayers))
for _, l := range rtxTestLayers {
simTracks[l.ssrc] = sfuinterceptor.SimulcastTrackInfo{Mid: rtxTestMid, StreamID: l.rid}
simTracks[l.rtxSSRC] = sfuinterceptor.SimulcastTrackInfo{
Mid: rtxTestMid,
StreamID: l.rid,
IsRepairStream: true,
}
}
newRTXHarness(t, simTracks).run(t, omitsExtensions)
}
// -----------------------------------------------------------------------------
// harness
// -----------------------------------------------------------------------------
type rtxHarness struct {
transport *rtc.PCTransport
pubPC *webrtc.PeerConnection
writers []*simulcastWriter
tracker *rtxPairTracker
}
func newRTXHarness(t *testing.T, simTracks map[uint32]sfuinterceptor.SimulcastTrackInfo) *rtxHarness {
t.Helper()
hosts := vnettest.NewHosts(t)
tracker := newRTXPairTracker()
bufferFactory := buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory()
pcTransport := newPublisherTransportForTest(t, hosts.AnswerNet, bufferFactory, simTracks, tracker)
pubPC, writers := newSimulcastPublisherPC(t, hosts.OfferNet)
return &rtxHarness{
transport: pcTransport,
pubPC: pubPC,
writers: writers,
tracker: tracker,
}
}
// run negotiates, publishes every layer, then retransmits a sequence number that is
// never sent on the primary stream and waits for it to surface on the primary buffer.
func (h *rtxHarness) run(t *testing.T, omitExtensions bool) {
t.Helper()
signalToTransport(t, h.pubPC, h.transport)
require.Equal(t, rtxTestMid, h.pubPC.GetTransceivers()[0].Mid())
for _, w := range h.writers {
w.mid = rtxTestMid
w.omitExtensions = omitExtensions
}
// every layer has to bind before RTX is sent, which is also the production ordering:
// a retransmission only follows a NACK for an established layer
require.True(
t,
sendUntil(t, 20*time.Second, func() bool { return h.tracker.boundCount() == len(rtxTestLayers) }, func() {
for _, w := range h.writers {
w.writePrimary(t)
}
}),
"timed out waiting for all simulcast layers to bind: %s", h.tracker.describe(),
)
require.True(
t,
sendUntil(t, 20*time.Second, func() bool {
for _, w := range h.writers {
if !h.tracker.sawSeq(w.rid, w.recoveredSeq) {
return false
}
}
return true
}, func() {
for _, w := range h.writers {
w.writeRepair(t, h.tracker.rtxPayloadType(w.rid), w.recoveredSeq)
}
}),
"retransmissions never recovered into the primary buffers: %s", h.tracker.describe(),
)
}
// newPublisherTransportForTest builds the production publisher transport on net and
// wires up what ParticipantImpl/MediaTrack do with a published layer.
func newPublisherTransportForTest(
t *testing.T,
net *vnet.Net,
bufferFactory *buffer.Factory,
simTracks map[uint32]sfuinterceptor.SimulcastTrackInfo,
tracker *rtxPairTracker,
) *rtc.PCTransport {
t.Helper()
rtcConf := newVNetWebRTCConfig(t, net, bufferFactory)
handler := &transportfakes.FakeHandler{}
params := rtc.TransportParams{
Handler: handler,
Config: rtcConf,
DirectionConfig: rtcConf.Publisher,
ProtocolVersion: 6,
Logger: logger.GetLogger(),
Transport: livekit.SignalTarget_PUBLISHER,
SimTracks: simTracks,
EnabledPublishCodecs: []*livekit.Codec{
{Mime: webrtc.MimeTypeVP8},
{Mime: webrtc.MimeTypeRTX},
},
// all candidates are carried in the answer, so the test needs no trickle
UseOneShotSignallingMode: true,
}
pcTransport, err := rtc.NewPCTransport(params)
require.NoError(t, err)
t.Cleanup(pcTransport.Close)
// mirror mediatrack.addReceiver: bind the buffer of each published layer, subscribe
// to the pairing notification, and drain the buffer the way WebRTCReceiver does
handler.OnTrackCalls(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
rid, ssrc := track.RID(), uint32(track.SSRC())
buff := bufferFactory.GetBuffer(ssrc)
if buff == nil {
t.Errorf("no buffer for published ssrc %d (rid %q)", ssrc, rid)
return
}
if err := buff.Bind(receiver.GetParameters(), track.Codec().RTPCodecCapability, 0); err != nil {
t.Errorf("binding buffer for rid %q failed: %v", rid, err)
return
}
buff.OnNotifyRTX(func(base, repair uint32, rsid string) {
tracker.pairFound(rsid, base, repair)
})
// mirror ParticipantImpl.onMediaTrack
pcTransport.RTPStreamPublished(ssrc, pcTransport.GetMid(receiver), rid)
tracker.layerBound(rid, ssrc, buff, receiver.GetParameters())
go tracker.drain(rid, buff)
})
return pcTransport
}
// signalToTransport runs a one-shot offer/answer against the transport and waits for
// the publisher to connect.
func signalToTransport(t *testing.T, pub *webrtc.PeerConnection, pcTransport *rtc.PCTransport) {
t.Helper()
connected := vnettest.UntilConnected(pub)
offer := vnettest.GatheredOffer(t, pub)
offer.SDP = stripDeclaredSSRCs(offer.SDP)
require.NoError(t, pcTransport.HandleRemoteDescription(offer, 1))
answer, _, err := pcTransport.GetAnswer()
require.NoError(t, err)
require.NoError(t, pub.SetRemoteDescription(answer))
select {
case <-connected:
case <-time.After(30 * time.Second):
t.Fatal("timed out waiting for the publisher to connect")
}
}
// -----------------------------------------------------------------------------
// publisher: raw simulcast writer with a per-layer repair stream
// -----------------------------------------------------------------------------
// rawTrackLocal hands the test the negotiated write stream directly. Unlike
// TrackLocalStaticRTP it does not rewrite SSRC or payload type, which is what lets a
// repair stream be emitted on its own SSRC alongside the primary stream of the same rid.
type rawTrackLocal struct {
id string
streamID string
rid string
lock sync.Mutex
writers []webrtc.TrackLocalWriter
exts []webrtc.RTPHeaderExtensionParameter
}
func (t *rawTrackLocal) Bind(ctx webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) {
for _, c := range ctx.CodecParameters() {
if c.PayloadType != vnettest.VP8PayloadType {
continue
}
t.lock.Lock()
t.writers = append(t.writers, ctx.WriteStream())
t.exts = ctx.HeaderExtensions()
t.lock.Unlock()
return c, nil
}
return webrtc.RTPCodecParameters{}, fmt.Errorf("vp8 not negotiated for rid %q", t.rid)
}
func (t *rawTrackLocal) Unbind(webrtc.TrackLocalContext) error { return nil }
func (t *rawTrackLocal) ID() string { return t.id }
func (t *rawTrackLocal) RID() string { return t.rid }
func (t *rawTrackLocal) StreamID() string { return t.streamID }
func (t *rawTrackLocal) Kind() webrtc.RTPCodecType { return webrtc.RTPCodecTypeVideo }
func (t *rawTrackLocal) extensionID(uri string) uint8 {
t.lock.Lock()
defer t.lock.Unlock()
for _, e := range t.exts {
if e.URI == uri {
return uint8(e.ID)
}
}
return 0
}
func (t *rawTrackLocal) write(header *rtp.Header, payload []byte) {
t.lock.Lock()
writers := append([]webrtc.TrackLocalWriter(nil), t.writers...)
t.lock.Unlock()
for _, w := range writers {
_, _ = w.WriteRTP(header, payload)
}
}
// simulcastWriter emits the primary and repair streams of one simulcast layer.
type simulcastWriter struct {
track *rawTrackLocal
mid string
rid string
ssrc uint32
rtxSSRC uint32
// omitExtensions emulates a migrated publisher, which sends no mid/rid/rsid
omitExtensions bool
// recoveredSeq is only ever sent inside an RTX payload, never on the primary
// stream, so its arrival on the primary buffer proves RTX recovery worked
recoveredSeq uint16
lock sync.Mutex
seq uint16
}
func (w *simulcastWriter) nextSeq() uint16 {
w.lock.Lock()
defer w.lock.Unlock()
w.seq++
return w.seq
}
func (w *simulcastWriter) header(t *testing.T, ssrc uint32, pt uint8, seq uint16, rid, rsid string) *rtp.Header {
t.Helper()
h := &rtp.Header{
Version: 2,
PayloadType: pt,
SequenceNumber: seq,
Timestamp: uint32(seq) * 3000,
SSRC: ssrc,
}
if w.omitExtensions {
return h
}
midID := w.track.extensionID(sdp.SDESMidURI)
require.NotZero(t, midID, "sdes:mid not negotiated")
require.NoError(t, h.SetExtension(midID, []byte(w.mid)))
if rid != "" {
ridID := w.track.extensionID(sdp.SDESRTPStreamIDURI)
require.NotZero(t, ridID, "sdes:rtp-stream-id not negotiated")
require.NoError(t, h.SetExtension(ridID, []byte(rid)))
}
if rsid != "" {
rsidID := w.track.extensionID(sdp.SDESRepairRTPStreamIDURI)
require.NotZero(t, rsidID, "sdes:repaired-rtp-stream-id not negotiated")
require.NoError(t, h.SetExtension(rsidID, []byte(rsid)))
}
return h
}
func (w *simulcastWriter) writePrimary(t *testing.T) {
t.Helper()
w.track.write(w.header(t, w.ssrc, vnettest.VP8PayloadType, w.nextSeq(), w.rid, ""), vp8TestPayload())
}
// writeRepair emits an RFC 4588 repair packet: the sequence number being retransmitted
// is prepended to the payload.
func (w *simulcastWriter) writeRepair(t *testing.T, rtxPT uint8, originalSeq uint16) {
t.Helper()
if rtxPT == 0 {
rtxPT = vnettest.RTXPayloadType
}
inner := vp8TestPayload()
payload := make([]byte, 2+len(inner))
binary.BigEndian.PutUint16(payload[:2], originalSeq)
copy(payload[2:], inner)
w.track.write(w.header(t, w.rtxSSRC, rtxPT, w.nextSeq(), "", w.rid), payload)
}
func vp8TestPayload() []byte {
return []byte{0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00}
}
// newSimulcastPublisherPC builds the publishing peer connection and one writer per
// simulcast layer.
func newSimulcastPublisherPC(t *testing.T, net *vnet.Net) (*webrtc.PeerConnection, []*simulcastWriter) {
t.Helper()
pc := vnettest.NewPeerConnection(t, vnettest.PCConfig{
Net: net,
MediaEngine: vnettest.MediaEngineConfig{
Video: true,
HeaderExtensions: true,
SimulcastExtensions: true,
},
})
writers := make([]*simulcastWriter, 0, len(rtxTestLayers))
for _, l := range rtxTestLayers {
writers = append(writers, &simulcastWriter{
track: &rawTrackLocal{id: "video", streamID: "pion", rid: l.rid},
rid: l.rid,
ssrc: l.ssrc,
rtxSSRC: l.rtxSSRC,
recoveredSeq: l.recoveredSeq,
})
}
sender, err := pc.AddTrack(writers[0].track)
require.NoError(t, err)
for _, w := range writers[1:] {
require.NoError(t, sender.AddEncoding(w.track))
}
return pc, writers
}
// -----------------------------------------------------------------------------
// tracking
// -----------------------------------------------------------------------------
type rtxPairTracker struct {
lock sync.Mutex
bound map[string]uint32 // rid -> base ssrc
params map[string]webrtc.RTPParameters
pairs map[string][2]uint32 // rsid -> {base ssrc, repair ssrc}
seen map[string]map[uint16]bool // rid -> sequence numbers read off the primary buffer
}
func newRTXPairTracker() *rtxPairTracker {
return &rtxPairTracker{
bound: make(map[string]uint32),
params: make(map[string]webrtc.RTPParameters),
pairs: make(map[string][2]uint32),
seen: make(map[string]map[uint16]bool),
}
}
func (t *rtxPairTracker) layerBound(rid string, ssrc uint32, buff *buffer.Buffer, params webrtc.RTPParameters) {
t.lock.Lock()
defer t.lock.Unlock()
t.bound[rid] = ssrc
t.params[rid] = params
t.seen[rid] = make(map[uint16]bool)
}
func (t *rtxPairTracker) pairFound(rsid string, base, repair uint32) {
t.lock.Lock()
defer t.lock.Unlock()
t.pairs[rsid] = [2]uint32{base, repair}
}
func (t *rtxPairTracker) boundCount() int {
t.lock.Lock()
defer t.lock.Unlock()
return len(t.bound)
}
func (t *rtxPairTracker) pairCount() int {
t.lock.Lock()
defer t.lock.Unlock()
return len(t.pairs)
}
func (t *rtxPairTracker) pair(rid string) (uint32, uint32) {
t.lock.Lock()
defer t.lock.Unlock()
p := t.pairs[rid]
return p[0], p[1]
}
func (t *rtxPairTracker) rtxPayloadType(rid string) uint8 {
t.lock.Lock()
defer t.lock.Unlock()
for _, c := range t.params[rid].Codecs {
if c.MimeType == webrtc.MimeTypeRTX {
return uint8(c.PayloadType)
}
}
return 0
}
func (t *rtxPairTracker) sawSeq(rid string, seq uint16) bool {
t.lock.Lock()
defer t.lock.Unlock()
return t.seen[rid][seq]
}
// drain consumes the primary buffer the way WebRTCReceiver does, recording which
// sequence numbers made it through.
func (t *rtxPairTracker) drain(rid string, buff *buffer.Buffer) {
b := make([]byte, 1500)
for {
ep, err := buff.ReadExtended(b)
if err != nil {
return
}
if ep == nil || ep.Packet == nil {
continue
}
t.lock.Lock()
t.seen[rid][ep.Packet.SequenceNumber] = true
t.lock.Unlock()
}
}
func (t *rtxPairTracker) describe() string {
t.lock.Lock()
defer t.lock.Unlock()
return fmt.Sprintf("bound=%v pairs=%v", t.bound, t.pairs)
}

Some files were not shown because too many files have changed in this diff Show More