mirror of
https://github.com/livekit/livekit.git
synced 2026-09-16 04:15:54 +00:00
Merge remote-tracking branch 'origin/master' into pr-4779-local
# Conflicts: # go.mod # go.sum # pkg/agent/worker.go
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
[](https://github.com/livekit/livekit/actions/workflows/buildtest.yaml)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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...")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user