diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml new file mode 100644 index 000000000..a0a854eaf --- /dev/null +++ b/.github/workflows/test-server-docker.yaml @@ -0,0 +1,53 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Builds and publishes the SDK conformance test server (cmd/test-server) to +# Docker Hub as livekit/test-server:latest on every push to master. The server +# SDK repos boot this image in their CI to run region-failover tests. +name: Release Test Server to Docker + +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to DockerHub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + file: cmd/test-server/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + # Each build overrides the latest tag so SDK CI always boots the + # current mock server. + tags: livekit/test-server:latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 233153522..9f4f6bc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.2] - 2026-06-27 + +### Added + +- Add Prometheus metrics for join latency and peer connection state (#4574, #4616) +- Preserve original expiry when refreshing token (#4580) +- Add grants expiry to Auth context (#4581) +- Add ability to run pprof on dedicated HTTP server (#4584) +- Add API to get latest node stats (#4589) +- Enforce subscription permission to data track (#4588) +- egress v2 api (#4592) +- agent: thread attributes map from dispatch to job (#4598) +- Log subscription limit breaches (#4603) +- Acquire requested video layer directly at HIGH quality by default (#4595) +- Report participant capabilities in ParticipantInfo (#4606) +- Add option to force drain rtcService/agentService connections (#4618) +- Add support for data blob (a. k. a. async participant attributes) (#4619) + +### Changed + +- Update dependencies: pion/sctp, DTLS v3.1.4, protocol (#4587, #4601, #4623) +- rtc: add RestartSessionTimer to re-anchor participant session duration (#4566) +- Record more RTC cancellation points (#4600) +- Cap all metadata at 512 KiB; enforce on join, agent dispatch, and embedded agents (#4602) +- Tighten up publish latency stat (#4615) +- Echo offered audio payload types in single-PC subscriber answer (#4614) + +### Fixed + +- Fix skipped packets accounting (#4604) +- Do not log due to negative getting interpreted as large unsigned positive (#4605) +- Do not call nil callback (#4607) + ## [1.13.1] - 2026-06-08 ### Fixed diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile new file mode 100644 index 000000000..0182aadea --- /dev/null +++ b/cmd/test-server/Dockerfile @@ -0,0 +1,40 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Mock LiveKit API server for SDK conformance testing. Build from the repo +# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +FROM golang:1.26-alpine AS builder + +ARG TARGETARCH + +WORKDIR /workspace + +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY cmd/ cmd/ +COPY pkg/ pkg/ +COPY version/ version/ + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server + +FROM alpine + +COPY --from=builder /workspace/livekit-test-server /livekit-test-server + +# region-0 (primary, 9999) + 3 fallback regions +EXPOSE 9999 10000 10001 10002 + +ENTRYPOINT ["/livekit-test-server"] diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md new file mode 100644 index 000000000..08dd75122 --- /dev/null +++ b/cmd/test-server/README.md @@ -0,0 +1,84 @@ +# LiveKit SDK test server + +A stateless, per-request programmable mock of the LiveKit server HTTP API. It +exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise +client-side behavior against one shared implementation, published as a Docker +image and booted by each SDK's CI. + +## Why it looks the way it does + +- **Stateless.** All behavior is selected by per-request `X-Lk-Mock-*` headers, + so the server holds no mutable state and tests run in parallel. +- **Multi-port = multi-region.** The process binds one listener per simulated + region (`--ports`). A port's position in the list is its **region index**; + index `0` is the primary the SDK is initially pointed at. `GET + /settings/regions` advertises all of them in order. +- **One header drives every attempt.** The SDK sends the same control header on + the initial request *and* every failover retry. Each listener decides what to + do from its **own** index, so a single `X-Lk-Mock-Fail-Regions: 0` makes the + primary fail while the first fallback succeeds — no coordination needed. +- **The whole API is mocked with populated responses.** Every RoomService, + Egress, Ingress, SIP, and Connector method returns a type-correct, populated + response: scalar fields that share a name with the request are echoed (e.g. + `name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder + values, and list endpoints return one element. Both protobuf and JSON Twirp + clients are supported. A client can override the response entirely with the + `X-Lk-Mock-Response` header (see below). Unregistered/future methods fall back + to an empty (all-default) message, which still decodes cleanly. + +## Running + +```bash +go run ./cmd/test-server # primary :9999, regions :10000-10002 +go run ./cmd/test-server --ports 9999,10000 # primary + one fallback + +# Docker +docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +docker run -p 9999-10002:9999-10002 livekit/test-server +``` + +| Flag | Env | Default | Meaning | +|---|---|---|---| +| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position | +| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` | +| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address | +| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix | + +## Control protocol + +Request headers (sent by the SDK on API calls; the SDK must forward +client-configured custom headers onto the `/settings/regions` fetch and every +failover retry): + +| Header | Default | Effect | +|---|---|---| +| `X-Lk-Mock-Fail-Regions` | — | comma list of region indices that fail this request, e.g. `0` or `0,1`. Each listener fails only if its own index is listed. | +| `X-Lk-Mock-Fail-Mode` | `status` | how a failing region fails: `status`, `drop` (close connection → transport error), `delay`. | +| `X-Lk-Mock-Fail-Status` | `503` | HTTP status when failing with `status`/`delay`. | +| `X-Lk-Mock-Fail-Twirp-Code` | derived from status | Twirp error code string in the failure body. | +| `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | +| `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | +| `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | + +Response headers: + +| Header | Meaning | +|---|---| +| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | + +## Common recipes + +| Goal | Headers | +|---|---| +| Happy path | _(none)_ → 200 from region `0` | +| Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | +| Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | +| All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | +| 4xx, no retry | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Status: 400` | +| Transport-error failover | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Mode: drop` | +| Region discovery unreachable | `X-Lk-Mock-Regions-Status: 500` | +| Custom response payload | `X-Lk-Mock-Response: {"sid":"RM_x","name":"my-room"}` | + +Note: SDK region failover normally only engages for `*.livekit.cloud` hosts. +Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its +forced-on value so failover engages against localhost. diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go new file mode 100644 index 000000000..f1568db95 --- /dev/null +++ b/cmd/test-server/handlers.go @@ -0,0 +1,228 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "io" + "net/http" + "strconv" + "strings" + + "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" +) + +// apiSpec captures the request and response message types for one Twirp method, +// so the mock can decode the incoming request and build a typed response. +type apiSpec struct { + newReq func() proto.Message + newResp func() proto.Message +} + +// ptrMsg constrains a pointer type that is also a proto.Message, letting reg +// construct fresh request/response values generically. +type ptrMsg[T any] interface { + *T + proto.Message +} + +// apiHandlers maps "./" to its message types. It +// covers the full LiveKit API surface; see init below. +var apiHandlers = map[string]apiSpec{} + +func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) { + apiHandlers[key] = apiSpec{ + newReq: func() proto.Message { return Req(new(ReqT)) }, + newResp: func() proto.Message { return Resp(new(RespT)) }, + } +} + +func init() { + // RoomService + reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom") + reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms") + reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom") + reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants") + reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant") + reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant") + reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack") + reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant") + reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions") + reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData") + reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata") + reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant") + reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant") + reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc") + + // Egress + reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress") + reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout") + reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream") + reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress") + reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress") + reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress") + reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress") + reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress") + reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress") + reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress") + + // Ingress + reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress") + reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress") + reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress") + reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress") + + // SIP + reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk") + reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk") + reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk") + reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk") + reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk") + reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk") + reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk") + reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk") + reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk") + reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk") + reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule") + reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule") + reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule") + reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule") + reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant") + reg[livekit.TransferSIPParticipantRequest, emptypb.Empty]("livekit.SIP/TransferSIPParticipant") + + // Connector + reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall") + reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall") + reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall") + reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall") + reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall") +} + +// writeAPIResponse serves a populated, type-correct response for a known API +// method. The response is the reflection-populated default unless the request +// carries an X-Lk-Mock-Response header (protojson), which overrides it +// entirely. Content type (protobuf vs JSON) mirrors the request. +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) { + json := strings.Contains(r.Header.Get("Content-Type"), "json") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + + key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") + spec, ok := apiHandlers[key] + if !ok { + // Unknown/future method: an empty body still decodes to a valid default + // message in every Twirp client. + writeEmptySuccess(w, json) + return + } + + body, _ := io.ReadAll(r.Body) + req := spec.newReq() + if json { + _ = protojson.Unmarshal(body, req) + } else { + _ = proto.Unmarshal(body, req) + } + + resp := spec.newResp() + if override := r.Header.Get(headerResponse); override != "" { + if err := protojson.Unmarshal([]byte(override), resp); err != nil { + // Malformed override: fall back to the populated default. + resp = spec.newResp() + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + } else { + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + + if json { + out, _ := protojson.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } else { + out, _ := proto.Marshal(resp) + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } +} + +func writeEmptySuccess(w http.ResponseWriter, json bool) { + if json { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + } else { + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + } +} + +// populateMessage fills a response message with plausible values: it echoes +// scalar fields that share a name with the request, assigns placeholder values +// to id/sid fields, and adds one element to repeated-message (list) fields so +// list endpoints return non-empty results. depth bounds list-element nesting. +func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) { + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + fd := fields.Get(i) + + // Echo a same-named scalar field from the request (e.g. name, metadata, + // identity, room, timeouts). + if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) { + if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil && + rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) { + m.Set(fd, req.Get(rf)) + continue + } + } + + // Give id/sid-like string fields a deterministic placeholder. + if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) { + n := string(fd.Name()) + if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") { + m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n))) + continue + } + } + + // Populate list endpoints with a single element so clients see results. + if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind { + list := m.Mutable(fd).List() + elem := list.NewElement() + populateMessage(elem.Message(), nil, depth-1) + list.Append(elem) + } + } +} + +func isScalarKind(k protoreflect.Kind) bool { + switch k { + case protoreflect.BoolKind, + protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind, + protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind, + protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind, + protoreflect.FloatKind, protoreflect.DoubleKind, + protoreflect.StringKind, protoreflect.BytesKind: + return true + default: + return false + } +} diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go new file mode 100644 index 000000000..66b756d47 --- /dev/null +++ b/cmd/test-server/main.go @@ -0,0 +1,247 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command test-server is a programmable mock of the LiveKit server HTTP API, +// used by the server SDKs to test client behavior. See cmd/test-server/README.md. +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/protojson" +) + +// X-Lk-Mock-* request headers control the mock's behavior; see the README. +const ( + headerFailRegions = "X-Lk-Mock-Fail-Regions" + headerFailMode = "X-Lk-Mock-Fail-Mode" + headerFailStatus = "X-Lk-Mock-Fail-Status" + headerFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code" + headerDelayMs = "X-Lk-Mock-Delay-Ms" + headerRegionsStatus = "X-Lk-Mock-Regions-Status" + headerResponse = "X-Lk-Mock-Response" + // headerRegion is set on responses to the index of the region that served it. + headerRegion = "X-Lk-Mock-Region" +) + +const defaultDelayMs = 30_000 + +func main() { + portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002") + advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") + bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0") + twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp") + + ports, err := parsePorts(portsFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err) + os.Exit(1) + } + advertiseHost = strings.TrimRight(advertiseHost, "/") + + regions := &livekit.RegionSettings{} + for i, p := range ports { + regions.Regions = append(regions.Regions, &livekit.RegionInfo{ + Region: fmt.Sprintf("region-%d", i), + Url: fmt.Sprintf("%s:%d", advertiseHost, p), + Distance: int64(i), + }) + } + + errCh := make(chan error, len(ports)) + for i, p := range ports { + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", bindAddr, p), + Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix}, + } + go func() { errCh <- srv.ListenAndServe() }() + fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + select { + case err := <-errCh: + fmt.Fprintf(os.Stderr, "listener failed: %v\n", err) + os.Exit(1) + case <-sigCh: + fmt.Println("test-server: shutting down") + } +} + +type mockHandler struct { + regionIndex int + regions *livekit.RegionSettings + twirpPrefix string +} + +func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/settings/regions": + h.handleRegions(w, r) + case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"): + h.handleTwirp(w, r) + case r.URL.Path == "/" || r.URL.Path == "/_test/health": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + default: + http.NotFound(w, r) + } +} + +func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { + if status := parseStatus(r.Header.Get(headerRegionsStatus), 0); status != 0 && status != http.StatusOK { + w.WriteHeader(status) + return + } + body, err := protojson.Marshal(h.regions) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "max-age=0") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + _, _ = w.Write(body) +} + +func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { + if h.shouldFail(r) { + h.fail(w, r) + return + } + h.writeAPIResponse(w, r) +} + +func (h *mockHandler) shouldFail(r *http.Request) bool { + for _, part := range strings.Split(r.Header.Get(headerFailRegions), ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if idx, err := strconv.Atoi(part); err == nil && idx == h.regionIndex { + return true + } + } + return false +} + +func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) { + switch strings.ToLower(r.Header.Get(headerFailMode)) { + case "drop": + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() + return + } + } + w.WriteHeader(http.StatusServiceUnavailable) + case "delay": + delay := defaultDelayMs + if ms, err := strconv.Atoi(r.Header.Get(headerDelayMs)); err == nil && ms >= 0 { + delay = ms + } + time.Sleep(time.Duration(delay) * time.Millisecond) + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + default: + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + } +} + +func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { + code := r.Header.Get(headerFailTwirpCode) + if code == "" { + code = twirpCodeForStatus(status) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerRegion, "") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status)) +} + +func twirpCodeForStatus(status int) string { + switch { + case status == http.StatusBadRequest: + return "invalid_argument" + case status == http.StatusUnauthorized: + return "unauthenticated" + case status == http.StatusForbidden: + return "permission_denied" + case status == http.StatusNotFound: + return "not_found" + case status == http.StatusTooManyRequests: + return "resource_exhausted" + case status >= 500: + return "unavailable" + default: + return "internal" + } +} + +func parseStatus(s string, def int) int { + if s == "" { + return def + } + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 { + return v + } + return def +} + +func parsePorts(s string) ([]int, error) { + var ports []int + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("%q is not a port number", part) + } + ports = append(ports, v) + } + if len(ports) == 0 { + return nil, errors.New("at least one port is required") + } + return ports, nil +} + +// flagValue resolves a setting from a --flag, then an environment variable, then a default. +func flagValue(flag, env, def string) string { + prefix := flag + "=" + for i, arg := range os.Args[1:] { + if arg == flag { + if i+2 <= len(os.Args[1:]) { + return os.Args[1:][i+1] + } + } + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + } + if v := os.Getenv(env); v != "" { + return v + } + return def +} diff --git a/go.mod b/go.mod index 4b561b67d..84378d367 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b + github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 @@ -30,20 +30,20 @@ require ( github.com/moby/moby/client v0.4.1 github.com/olekukonko/tablewriter v1.1.4 github.com/ory/dockertest/v4 v4.0.0 - github.com/pion/datachannel v1.6.0 + github.com/pion/datachannel v1.6.2 github.com/pion/dtls/v3 v3.1.4 github.com/pion/ice/v4 v4.2.7 github.com/pion/interceptor v0.1.45 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.2 - github.com/pion/sctp v1.9.5 - github.com/pion/sdp/v3 v3.0.18 + github.com/pion/sctp v1.10.3 + github.com/pion/sdp/v3 v3.0.19 github.com/pion/transport/v4 v4.0.2 - github.com/pion/turn/v5 v5.0.8 - github.com/pion/webrtc/v4 v4.2.11 + github.com/pion/turn/v5 v5.0.10 + github.com/pion/webrtc/v4 v4.2.16 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 - github.com/redis/go-redis/v9 v9.20.0 + github.com/redis/go-redis/v9 v9.21.0 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.11.1 github.com/thoas/go-funk v0.9.3 @@ -54,8 +54,8 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/mod v0.36.0 - golang.org/x/sync v0.20.0 + golang.org/x/mod v0.37.0 + golang.org/x/sync v0.21.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -92,7 +92,7 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/time v0.15.0 // indirect ) @@ -134,22 +134,22 @@ require ( github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/srtp/v3 v3.0.11 // indirect - github.com/pion/stun/v3 v3.1.4 + github.com/pion/srtp/v3 v3.0.12 // indirect + github.com/pion/stun/v3 v3.1.6 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/urfave/cli/v3 v3.9.0 + github.com/urfave/cli/v3 v3.10.0 github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.45.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.46.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.81.1 // indirect ) diff --git a/go.sum b/go.sum index 3ea04b90f..3a0b13d74 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b h1:7gH58XkJ0wtU+5d33Jkktmf4I3heKjQzDhwYHDSGmTo= -github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 h1:Rj9/54oztXeioAwUkukXBwcPE/GxL97WylFd1m7V2pQ= +github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= @@ -229,8 +229,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= -github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= -github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= @@ -247,24 +247,22 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag= -github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= -github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= -github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= -github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= -github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= -github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY= -github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U= +github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI= +github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= -github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= -github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8= -github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU= -github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU= -github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY= +github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= +github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q= +github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -274,14 +272,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= -github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= -github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -305,8 +303,8 @@ github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJX github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c h1:XbG4n3OWA1PcRTpbBA22E2ChPLvJCuwYRXO12tIyVL0= github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c/go.mod h1:gwANdYmo9R8LLwGnyDFWK2PMsaXXX2HhAvCnb/UhZsM= -github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= -github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= +github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= @@ -351,12 +349,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= -golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -371,12 +369,12 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -400,29 +398,29 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/magefile.go b/magefile.go index 4423b01c9..d4d42fa68 100644 --- a/magefile.go +++ b/magefile.go @@ -177,6 +177,11 @@ func TestAll() error { return mageutil.Run(context.Background(), "go test ./... -count=1 -timeout=4m -v") } +// runs the SDK test server (cmd/test-server) in the foreground +func TestServer() error { + return mageutil.Run(context.Background(), "go run ./cmd/test-server") +} + // runs golangci-lint func Lint() error { if _, err := exec.LookPath("golangci-lint"); err != nil { diff --git a/pkg/agent/testutils/server.go b/pkg/agent/testutils/server.go index 090e37680..3f490918c 100644 --- a/pkg/agent/testutils/server.go +++ b/pkg/agent/testutils/server.go @@ -29,7 +29,7 @@ import ( type AgentService interface { HandleConnection(context.Context, agent.SignalConn, agent.WorkerRegistration) - DrainConnections(time.Duration) + DrainConnections(time.Duration, bool) } type TestServer struct { @@ -140,7 +140,7 @@ func (h *TestServer) SimulateAgentWorker(opts ...SimulatedWorkerOption) *AgentWo } func (h *TestServer) Close() { - h.DrainConnections(1) + h.DrainConnections(1, false) } var _ agent.SignalConn = (*AgentWorker)(nil) diff --git a/pkg/config/config.go b/pkg/config/config.go index 4393f7f80..f85f6a841 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -91,6 +91,8 @@ type Config struct { EnableDataTracks bool `yaml:"enable_data_tracks,omitempty"` + EnableParticipantDataBlob bool `yaml:"enable_participant_data_blob,omitempty"` + API APIConfig `yaml:"api,omitempty"` } @@ -280,6 +282,8 @@ type RegionConfig struct { Lon float64 `yaml:"lon,omitempty"` } +// --------------------------------- + type LimitConfig struct { NumTracks int32 `yaml:"num_tracks,omitempty"` BytesPerSec float32 `yaml:"bytes_per_sec,omitempty"` @@ -291,6 +295,9 @@ type LimitConfig struct { MaxRoomNameLength int `yaml:"max_room_name_length,omitempty"` MaxParticipantIdentityLength int `yaml:"max_participant_identity_length,omitempty"` MaxParticipantNameLength int `yaml:"max_participant_name_length,omitempty"` + + MaxDataBlobKeyLength int `yaml:"max_data_blob_key_length,omitempty"` + MaxDataBlobSize uint32 `yaml:"max_data_blobs_size,omitempty"` } func (l LimitConfig) CheckRoomNameLength(name string) bool { @@ -321,6 +328,36 @@ func (l LimitConfig) CheckAttributesSize(attributes map[string]string) bool { return uint32(total) <= l.MaxAttributesSize } +func (l LimitConfig) CheckDataBlobKeyLength(key string) bool { + return l.MaxDataBlobKeyLength == 0 || len(key) <= l.MaxDataBlobKeyLength +} + +func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool { + if l.MaxDataBlobSize == 0 { + return true + } + + total := 0 + for _, dataBlob := range dataBlobs { + total += len(dataBlob.GetKey().String()) + len(dataBlob.Contents) + } + return uint32(total) <= l.MaxDataBlobSize +} + +func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *livekit.DataBlob) bool { + if l.MaxDataBlobSize == 0 { + return true + } + + total := 0 + for _, dataBlob := range dataBlobs { + total += len(dataBlob.Key.String()) + len(dataBlob.Contents) + } + return uint32(total+len(toAdd.GetKey().String())+len(toAdd.Contents)) <= l.MaxDataBlobSize +} + +// --------------------------------- + type IngressConfig struct { RTMPBaseURL string `yaml:"rtmp_base_url,omitempty"` WHIPBaseURL string `yaml:"whip_base_url,omitempty"` @@ -443,6 +480,8 @@ var DefaultConfig = Config{ MaxRoomNameLength: 256, MaxParticipantIdentityLength: 256, MaxParticipantNameLength: 256, + MaxDataBlobKeyLength: 256, + MaxDataBlobSize: 64000, }, Logging: LoggingConfig{ PionLevel: "error", diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index c85901bac..2656d3ae0 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -225,8 +225,10 @@ type ParticipantParams struct { EnableRTPStreamRestartDetection bool ForceBackupCodecPolicySimulcast bool DisableTransceiverReuseForE2EE bool + EnableParticipantDataBlob bool EnableStartAtDesiredQuality bool - VideoFrameCachingDuration time.Duration + MigrationWaitDuration time.Duration + VideoFrameCachingDuration time.Duration } type ParticipantImpl struct { @@ -335,6 +337,8 @@ type ParticipantImpl struct { rpcLock sync.Mutex rpcPendingAcks map[string]*utils.DataChannelRpcPendingAckHandler rpcPendingResponses map[string]*utils.DataChannelRpcPendingResponseHandler + + dataBlob *ParticipantDataBlob } func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) { @@ -373,6 +377,9 @@ func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) { telemetryGuard: &telemetry.ReferenceGuard{}, nextSubscribedDataTrackHandle: uint16(rand.Intn(256)), requireBroadcast: params.Grants.Metadata != "" || len(params.Grants.Attributes) != 0, + dataBlob: NewParticipantDataBlob(ParticipantDataBlobParams{ + Logger: params.Logger, + }), } p.setupSignalling() @@ -912,6 +919,7 @@ func (p *ParticipantImpl) ToProtoWithVersion() (*livekit.ParticipantInfo, utils. KindDetails: grants.GetKindDetails(), DisconnectReason: p.CloseReason().ToDisconnectReason(), ClientProtocol: clientProtocol, + Capabilities: p.params.ClientInfo.GetCapabilities(), } p.lock.RUnlock() @@ -1340,9 +1348,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { return } - p.pendingTracksLock.Lock() - ti := p.addPendingTrackLocked(req) - p.pendingTracksLock.Unlock() + ti := p.addPendingTrack(req) if ti == nil { return } @@ -1568,7 +1574,7 @@ func (p *ParticipantImpl) setupMigrationTimerLocked() { // to try and succeed. If not, close the subscriber peer connection // and help the remote side to narrow down its ICE candidate pool. // - p.migrationTimer = time.AfterFunc(migrationWaitDuration, func() { + p.migrationTimer = time.AfterFunc(max(p.params.MigrationWaitDuration, migrationWaitDuration), func() { p.clearMigrationTimer() if p.IsClosed() || p.IsDisconnected() { @@ -2852,7 +2858,10 @@ func (p *ParticipantImpl) onSubscribedAudioCodecChange( return p.sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate) } -func (p *ParticipantImpl) addPendingTrackLocked(req *livekit.AddTrackRequest) *livekit.TrackInfo { +func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit.TrackInfo { + p.pendingTracksLock.Lock() + defer p.pendingTracksLock.Unlock() + if req.Sid != "" { track := p.GetPublishedTrack(livekit.TrackID(req.Sid)) if track == nil { @@ -3249,11 +3258,11 @@ func (p *ParticipantImpl) mediaTrackReceived( mt = p.addMediaTrack(signalCid, ti) newTrack = true - // if the addTrackRequest is sent before participant active then it means the client tries to publish - // before fully connected, in this case we only record the time when the participant is active since + // if the addTrackRequest is sent before publisher peer connection is established, then it means the client tries to publish + // before fully connected, in this case we only record the time when publisher peer connection is established since // we want this metric to represent the time cost by publishing. - if activeAt := p.lastActiveAt.Load(); activeAt != nil && createdAt.Before(*activeAt) { - createdAt = *activeAt + if connectedAt := p.TransportManager.PublisherFirstConnectedAt(); !connectedAt.IsZero() && createdAt.Before(connectedAt) { + createdAt = connectedAt } pubTime = time.Since(createdAt) p.dirty.Store(true) diff --git a/pkg/rtc/participant_data_blob.go b/pkg/rtc/participant_data_blob.go new file mode 100644 index 000000000..74512d569 --- /dev/null +++ b/pkg/rtc/participant_data_blob.go @@ -0,0 +1,90 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rtc + +import ( + "sync" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +type ParticipantDataBlobParams struct { + Logger logger.Logger +} + +type ParticipantDataBlob struct { + params ParticipantDataBlobParams + lock sync.Mutex + blobs map[string]*livekit.DataBlob +} + +func NewParticipantDataBlob(params ParticipantDataBlobParams) *ParticipantDataBlob { + return &ParticipantDataBlob{ + params: params, + blobs: make(map[string]*livekit.DataBlob), + } +} + +func (p *ParticipantDataBlob) Add(db *livekit.DataBlob) { + p.lock.Lock() + defer p.lock.Unlock() + + if db.Key == nil { + return + } + + p.blobs[db.Key.String()] = db +} + +func (p *ParticipantDataBlob) Delete(dbKey *livekit.DataBlobKey) { + p.lock.Lock() + defer p.lock.Unlock() + + if dbKey == nil { + return + } + + delete(p.blobs, dbKey.String()) +} + +func (p *ParticipantDataBlob) Get(dbKey *livekit.DataBlobKey) *livekit.DataBlob { + p.lock.Lock() + defer p.lock.Unlock() + + if dbKey == nil { + return nil + } + + db, ok := p.blobs[dbKey.String()] + if !ok { + return nil + } + + return db +} + +func (p *ParticipantDataBlob) GetAll() []*livekit.DataBlob { + p.lock.Lock() + defer p.lock.Unlock() + + all := make([]*livekit.DataBlob, 0, len(p.blobs)) + for _, db := range p.blobs { + all = append(all, db) + } + return all +} + +// ------------------------------- diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go new file mode 100644 index 000000000..e6ebcc8ed --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler.go @@ -0,0 +1,113 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rtc + +import ( + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobRequest) { + if !p.params.EnableParticipantDataBlob { + p.pubLogger.Warnw("data blob not enabled", nil, "req", logger.Proto(req)) + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_NOT_ALLOWED, + Message: "data blob not enabled", + }) + return + } + + if req.Blob == nil || req.Blob.Key == nil || len(req.Blob.Key.String()) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key.String()) { + p.pubLogger.Warnw("data blob is invalid", nil, "req", logger.Proto(req)) + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_INVALID_REQUEST, + Message: "data blob is invalid", + }) + return + } + + if len(req.Blob.Contents) == 0 { + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_INVALID_REQUEST, + Message: "data blob is empty", + }) + return + } + + if !p.params.LimitConfig.CanAddDataBlob(p.dataBlob.GetAll(), req.Blob) { + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_LIMIT_EXCEEDED, + Message: "async attribute definition exceeds limit", + }) + return + } + + p.AddDataBlob(req.Blob) + p.listener().OnStoreDataBlob(p, req.Blob) + p.sendStoreDataBlobResponse(req.RequestId, req.Blob.Key) +} + +func (p *ParticipantImpl) HandleGetDataBlobRequest(req *livekit.GetDataBlobRequest) { + if req.Key == nil { + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_INVALID_REQUEST, + Message: "data blob key is required", + }) + return + } + + p.listener().OnGetDataBlob(p, req) +} + +func (p *ParticipantImpl) AddDataBlob(dataBlob *livekit.DataBlob) { + p.dataBlob.Add(dataBlob) +} + +func (p *ParticipantImpl) GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob { + return p.dataBlob.Get(key) +} + +func (p *ParticipantImpl) ProcessGetDataBlobRequest(req *livekit.GetDataBlobRequest, publisher types.Participant) { + if publisher == nil { + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_NOT_FOUND, + Message: "participant not found", + }) + return + } + + dataBlob := publisher.GetDataBlob(req.Key) + if dataBlob == nil { + p.sendRequestResponse(&livekit.RequestResponse{ + RequestId: req.RequestId, + Reason: livekit.RequestResponse_NOT_FOUND, + Message: "data blob not found", + }) + return + } + + p.sendGetDataBlobResponse(req.RequestId, dataBlob) +} + +func (p *ParticipantImpl) GetAllDataBlob() []*livekit.DataBlob { + return p.dataBlob.GetAll() +} diff --git a/pkg/rtc/participant_data_blob_handler_test.go b/pkg/rtc/participant_data_blob_handler_test.go new file mode 100644 index 000000000..411a6a203 --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler_test.go @@ -0,0 +1,300 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rtc + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing/routingfakes" + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" +) + +func newParticipantWithDataBlob(t *testing.T, enabled bool, maxKeyLength int, maxSize uint32) *ParticipantImpl { + t.Helper() + p := newParticipantForTest("test") + p.params.EnableParticipantDataBlob = enabled + p.params.LimitConfig = config.LimitConfig{ + MaxDataBlobKeyLength: maxKeyLength, + MaxDataBlobSize: maxSize, + } + return p +} + +func lastRequestResponse(t *testing.T, sink *routingfakes.FakeMessageSink, idx int) *livekit.RequestResponse { + t.Helper() + msg := sink.WriteMessageArgsForCall(idx).(*livekit.SignalResponse) + rr, ok := msg.Message.(*livekit.SignalResponse_RequestResponse) + require.True(t, ok, "expected SignalResponse_RequestResponse, got %T", msg.Message) + return rr.RequestResponse +} + +func TestHandleStoreDataBlobRequest(t *testing.T) { + t.Run("returns NOT_ALLOWED when feature not enabled", func(t *testing.T) { + p := newParticipantWithDataBlob(t, false, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + req := &livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: genericKey("blob-1"), + Contents: []byte("def"), + }, + } + p.HandleStoreDataBlobRequest(req) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_NOT_ALLOWED, rr.Reason) + require.Empty(t, p.dataBlob.GetAll()) + }) + + t.Run("returns INVALID_REQUEST when blob is nil", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{}) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + require.Empty(t, p.dataBlob.GetAll()) + }) + + t.Run("returns INVALID_REQUEST when key is nil", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Contents: []byte("def"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + }) + + t.Run("returns INVALID_REQUEST when key has no oneof set", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: &livekit.DataBlobKey{}, + Contents: []byte("def"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + }) + + t.Run("returns INVALID_REQUEST when key exceeds length limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 5, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: genericKey(strings.Repeat("a", 64)), + Contents: []byte("def"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + }) + + t.Run("returns INVALID_REQUEST when contents is empty", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: genericKey("blob-1"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + require.Empty(t, p.dataBlob.GetAll()) + }) + + t.Run("returns LIMIT_EXCEEDED when adding would breach the limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 16) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: genericKey("blob-1"), + Contents: []byte(strings.Repeat("x", 32)), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_LIMIT_EXCEEDED, rr.Reason) + require.Empty(t, p.dataBlob.GetAll()) + }) + + t.Run("stores a valid blob, notifies listener, and sends response", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener) + + key := genericKey("blob-1") + contents := []byte("definition-bytes") + blob := &livekit.DataBlob{Key: key, Contents: contents} + + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + RequestId: 42, + Blob: blob, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + response, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse) + require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message) + require.Equal(t, uint32(42), response.StoreDataBlobResponse.RequestId) + require.Equal(t, key, response.StoreDataBlobResponse.Key) + + stored := p.dataBlob.Get(key) + require.NotNil(t, stored) + require.Equal(t, contents, stored.Contents) + + require.Equal(t, 1, listener.OnStoreDataBlobCallCount()) + gotParticipant, gotBlob := listener.OnStoreDataBlobArgsForCall(0) + require.Equal(t, p, gotParticipant) + require.Equal(t, blob, gotBlob) + }) +} + +func TestHandleGetDataBlobRequest(t *testing.T) { + t.Run("returns INVALID_REQUEST when key is missing", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.HandleGetDataBlobRequest(&livekit.GetDataBlobRequest{ + ParticipantIdentity: "other", + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) + }) + + t.Run("forwards request to listener when key is provided", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener) + + req := &livekit.GetDataBlobRequest{ + ParticipantIdentity: "other", + Key: genericKey("blob-1"), + } + p.HandleGetDataBlobRequest(req) + + require.Equal(t, 1, listener.OnGetDataBlobCallCount()) + gotParticipant, gotReq := listener.OnGetDataBlobArgsForCall(0) + require.Equal(t, p, gotParticipant) + require.Equal(t, req, gotReq) + }) +} + +func TestGetDataBlob(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + + key := genericKey("blob-1") + require.Nil(t, p.GetDataBlob(key)) + + blob := &livekit.DataBlob{ + Key: key, + Contents: []byte("definition"), + } + p.dataBlob.Add(blob) + got := p.GetDataBlob(key) + require.NotNil(t, got) + require.Equal(t, key.String(), got.Key.String()) + require.Equal(t, []byte("definition"), got.Contents) +} + +func TestProcessGetDataBlobRequest(t *testing.T) { + t.Run("returns NOT_FOUND when publisher is nil", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{ + Key: genericKey("blob-1"), + }, nil) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason) + require.Contains(t, rr.Message, "participant") + }) + + t.Run("returns NOT_FOUND when publisher has no matching blob", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + publisher := &typesfakes.FakeParticipant{} + publisher.GetDataBlobReturns(nil) + + req := &livekit.GetDataBlobRequest{ + Key: genericKey("blob-1"), + } + p.ProcessGetDataBlobRequest(req, publisher) + + require.Equal(t, 1, publisher.GetDataBlobCallCount()) + require.Equal(t, req.Key, publisher.GetDataBlobArgsForCall(0)) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + rr := lastRequestResponse(t, sink, 0) + require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason) + }) + + t.Run("sends blob response when publisher has a matching blob", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + key := genericKey("blob-1") + blob := &livekit.DataBlob{ + Key: key, + Contents: []byte("definition-bytes"), + } + + publisher := &typesfakes.FakeParticipant{} + publisher.GetDataBlobReturns(blob) + + p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{ + RequestId: 42, + Key: key, + }, publisher) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + response, ok := msg.Message.(*livekit.SignalResponse_GetDataBlobResponse) + require.True(t, ok, "expected SignalResponse_GetDataBlobResponse, got %T", msg.Message) + require.Equal(t, uint32(42), response.GetDataBlobResponse.RequestId) + require.Equal(t, blob, response.GetDataBlobResponse.Blob) + }) +} diff --git a/pkg/rtc/participant_data_blob_test.go b/pkg/rtc/participant_data_blob_test.go new file mode 100644 index 000000000..a6d28fb20 --- /dev/null +++ b/pkg/rtc/participant_data_blob_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rtc + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +func newTestDataBlob() *ParticipantDataBlob { + return NewParticipantDataBlob(ParticipantDataBlobParams{ + Logger: logger.GetLogger(), + }) +} + +func genericKey(name string) *livekit.DataBlobKey { + return &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: name, + }, + } +} + +func TestParticipantDataBlob_AddAndGet(t *testing.T) { + a := newTestDataBlob() + + key := genericKey("blob-1") + contents := []byte("definition-bytes") + + a.Add(&livekit.DataBlob{Key: key, Contents: contents}) + + got := a.Get(key) + require.NotNil(t, got) + require.Equal(t, key.String(), got.Key.String()) + require.Equal(t, contents, got.Contents) +} + +func TestParticipantDataBlob_AddOverwrites(t *testing.T) { + a := newTestDataBlob() + + key := genericKey("blob-1") + a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v1")}) + a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v2")}) + + got := a.Get(key) + require.NotNil(t, got) + require.Equal(t, []byte("v2"), got.Contents) + + require.Len(t, a.GetAll(), 1) +} + +func TestParticipantDataBlob_DistinctKeys(t *testing.T) { + a := newTestDataBlob() + + key1 := genericKey("blob-1") + key2 := genericKey("blob-2") + + a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("c1")}) + a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("c2")}) + + got1 := a.Get(key1) + require.NotNil(t, got1) + require.Equal(t, []byte("c1"), got1.Contents) + + got2 := a.Get(key2) + require.NotNil(t, got2) + require.Equal(t, []byte("c2"), got2.Contents) + + require.Len(t, a.GetAll(), 2) +} + +func TestParticipantDataBlob_Delete(t *testing.T) { + a := newTestDataBlob() + + key := genericKey("blob-1") + a.Add(&livekit.DataBlob{Key: key, Contents: []byte("definition")}) + + a.Delete(key) + require.Nil(t, a.Get(key)) + require.Empty(t, a.GetAll()) + + // deleting a non-existent key is a no-op + a.Delete(key) + require.Empty(t, a.GetAll()) +} + +func TestParticipantDataBlob_NilKey(t *testing.T) { + a := newTestDataBlob() + + // nil key should be silently ignored, not panic + a.Add(&livekit.DataBlob{Contents: []byte("definition")}) + require.Empty(t, a.GetAll()) + + require.Nil(t, a.Get(nil)) + + a.Delete(nil) + require.Empty(t, a.GetAll()) +} + +func TestParticipantDataBlob_GetMissing(t *testing.T) { + a := newTestDataBlob() + + require.Nil(t, a.Get(genericKey("missing"))) +} + +func TestParticipantDataBlob_GetAllContents(t *testing.T) { + a := newTestDataBlob() + + key1 := genericKey("blob-1") + key2 := genericKey("blob-2") + + a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("def-1")}) + a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("def-2")}) + + all := a.GetAll() + require.Len(t, all, 2) + for _, db := range all { + switch key := db.Key.Key.(type) { + case *livekit.DataBlobKey_Generic: + switch key.Generic { + case "blob-1": + require.Equal(t, []byte("def-1"), db.Contents) + case "blob-2": + require.Equal(t, []byte("def-2"), db.Contents) + default: + require.Fail(t, "unexpected key", key.Generic) + } + default: + require.Fail(t, "unexpected key type", "Generic") + } + } +} + +func TestParticipantDataBlob_ConcurrentAccess(t *testing.T) { + a := newTestDataBlob() + + const numGoroutines = 16 + const opsPerGoroutine = 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + for g := 0; g < numGoroutines; g++ { + go func(g int) { + defer wg.Done() + for i := 0; i < opsPerGoroutine; i++ { + key := genericKey(fmt.Sprintf("blob-%d", g%8)) + a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v")}) + _ = a.Get(key) + _ = a.GetAll() + if i%3 == 0 { + a.Delete(key) + } + } + }(g) + } + wg.Wait() +} diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 3fa388adc..8f5c3dd2b 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -368,3 +368,17 @@ func (p *ParticipantImpl) SendDataTrackSubscriberHandles(handles map[uint32]*liv SubHandles: handles, })) } + +func (p *ParticipantImpl) sendStoreDataBlobResponse(requestId uint32, key *livekit.DataBlobKey) error { + return p.signaller.WriteMessage(p.signalling.SignalStoreDataBlobResponse(&livekit.StoreDataBlobResponse{ + RequestId: requestId, + Key: key, + })) +} + +func (p *ParticipantImpl) sendGetDataBlobResponse(requestId uint32, dataBlob *livekit.DataBlob) error { + return p.signaller.WriteMessage(p.signalling.SignalGetDataBlobResponse(&livekit.GetDataBlobResponse{ + RequestId: requestId, + Blob: dataBlob, + })) +} diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 86ddcd05e..66e922ec3 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1384,6 +1384,11 @@ func (r *Room) onUpdateDataSubscriptions(participant types.LocalParticipant, req } } +func (r *Room) onGetDataBlob(participant types.LocalParticipant, req *livekit.GetDataBlobRequest) { + publisher := r.GetParticipant(livekit.ParticipantIdentity(req.ParticipantIdentity)) + participant.ProcessGetDataBlobRequest(req, publisher) +} + func (r *Room) onLeave(p types.LocalParticipant, reason types.ParticipantCloseReason) { r.RemoveParticipant(p.Identity(), p.ID(), reason) } @@ -1996,6 +2001,13 @@ func (l *localParticipantListener) OnUpdateDataSubscriptions(p types.LocalPartic l.room.onUpdateDataSubscriptions(p, req) } +func (l *localParticipantListener) OnStoreDataBlob(_p types.LocalParticipant, _dataBlob *livekit.DataBlob) { +} + +func (l *localParticipantListener) OnGetDataBlob(p types.LocalParticipant, req *livekit.GetDataBlobRequest) { + l.room.onGetDataBlob(p, req) +} + func (l *localParticipantListener) OnSyncState(p types.LocalParticipant, state *livekit.SyncState) error { return l.room.onSyncState(p, state) } diff --git a/pkg/rtc/signalling/interfaces.go b/pkg/rtc/signalling/interfaces.go index 2470e9d31..99c3a0acc 100644 --- a/pkg/rtc/signalling/interfaces.go +++ b/pkg/rtc/signalling/interfaces.go @@ -62,4 +62,6 @@ type ParticipantSignalling interface { SignalPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse) proto.Message SignalUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse) proto.Message SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message + SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message + SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message } diff --git a/pkg/rtc/signalling/signalhandler.go b/pkg/rtc/signalling/signalhandler.go index 7e69aeee2..740e65899 100644 --- a/pkg/rtc/signalling/signalhandler.go +++ b/pkg/rtc/signalling/signalhandler.go @@ -151,6 +151,12 @@ func (s *signalhandler) HandleMessage(msg proto.Message) error { case *livekit.SignalRequest_UpdateDataSubscription: s.params.Participant.HandleUpdateDataSubscription(msg.UpdateDataSubscription) + + case *livekit.SignalRequest_StoreDataBlobRequest: + s.params.Participant.HandleStoreDataBlobRequest(msg.StoreDataBlobRequest) + + case *livekit.SignalRequest_GetDataBlobRequest: + s.params.Participant.HandleGetDataBlobRequest(msg.GetDataBlobRequest) } return nil diff --git a/pkg/rtc/signalling/signalling.go b/pkg/rtc/signalling/signalling.go index fffc2ba29..c13cee1be 100644 --- a/pkg/rtc/signalling/signalling.go +++ b/pkg/rtc/signalling/signalling.go @@ -258,3 +258,19 @@ func (s *signalling) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles }, } } + +func (s *signalling) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message { + return &livekit.SignalResponse{ + Message: &livekit.SignalResponse_StoreDataBlobResponse{ + StoreDataBlobResponse: storeDataBlobResponse, + }, + } +} + +func (s *signalling) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message { + return &livekit.SignalResponse{ + Message: &livekit.SignalResponse_GetDataBlobResponse{ + GetDataBlobResponse: getDataBlobResponse, + }, + } +} diff --git a/pkg/rtc/signalling/signallingunimplemented.go b/pkg/rtc/signalling/signallingunimplemented.go index dca48779f..8210c6aac 100644 --- a/pkg/rtc/signalling/signallingunimplemented.go +++ b/pkg/rtc/signalling/signallingunimplemented.go @@ -127,3 +127,11 @@ func (u *signallingUnimplemented) SignalUnpublishDataTrackResponse(unpublishData func (u *signallingUnimplemented) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message { return nil } + +func (u *signallingUnimplemented) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message { + return nil +} + +func (u *signallingUnimplemented) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message { + return nil +} diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 0848ae137..d94ef169b 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -1007,12 +1007,12 @@ func (t *PCTransport) queueOrConfigureSender( enableAudioNACK bool, ) { params := configureSenderParams{ - transceiver, - enabledCodecs, - rtcpFeedbackConfig, - !t.params.IsOfferer, - enableAudioStereo, - enableAudioNACK, + transceiver: transceiver, + enabledCodecs: enabledCodecs, + rtcpFeedbackConfig: rtcpFeedbackConfig, + filterOutH264HighProfile: !t.params.IsOfferer, + enableAudioStereo: enableAudioStereo, + enableAudioNACK: enableAudioNACK, } if !t.params.IsOfferer { t.sendersPendingConfigMu.Lock() @@ -1021,10 +1021,17 @@ func (t *PCTransport) queueOrConfigureSender( return } - configureSender(params) + // Offerer: no remote offer to echo payload types from. + configureSender(params, nil) } -func (t *PCTransport) processSendersPendingConfig() { +// processSendersPendingConfig configures the senders queued while answering the +// remote's offer (single peer connection mode). offerAudioPT (mime type -> the +// payload type the offer assigned) is parsed from the offer by the caller before +// SetRemoteDescription, so the answer echoes the offered payload types for audio +// codecs (and stays consistent with the forwarded RTP). It is nil when there is +// nothing to echo. +func (t *PCTransport) processSendersPendingConfig(offerAudioPT map[mime.MimeType]webrtc.PayloadType) { t.sendersPendingConfigMu.Lock() pending := t.sendersPendingConfig t.sendersPendingConfig = nil @@ -1037,7 +1044,7 @@ func (t *PCTransport) processSendersPendingConfig() { continue } - configureSender(p) + configureSender(p, offerAudioPT) } if len(unprocessed) != 0 { @@ -1432,6 +1439,13 @@ func (t *PCTransport) HasEverConnected() bool { return !t.firstConnectedAt.IsZero() } +func (t *PCTransport) FirstConnectedAt() time.Time { + t.lock.RLock() + defer t.lock.RUnlock() + + return t.firstConnectedAt +} + func (t *PCTransport) GetICEConnectionInfo() *types.ICEConnectionInfo { return t.connectionDetails.GetInfo() } @@ -2340,11 +2354,19 @@ func (t *PCTransport) handleICEGatheringCompleteAnswerer() error { t.pendingRestartIceOffer = nil t.params.Logger.Debugw("accept remote restart ice offer after ICE gathering") + + // Parse the offer payload types before SetRemoteDescription so this does not + // race with pion's use of the same description. + var offerAudioPT map[mime.MimeType]webrtc.PayloadType + if parsed, err := offer.Unmarshal(); err == nil { + offerAudioPT = offerAudioPayloadTypes(parsed) + } + if err := t.setRemoteDescription(offer); err != nil { return err } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig() + t.processSendersPendingConfig(offerAudioPT) return t.createAndSendAnswer() } @@ -2889,7 +2911,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig() + t.processSendersPendingConfig(offerAudioPayloadTypes(parsed)) rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger) if len(rtxRepairs) > 0 { @@ -3074,7 +3096,7 @@ type configureSenderParams struct { enableAudioNACK bool } -func configureSender(params configureSenderParams) { +func configureSender(params configureSenderParams, offerAudioPT map[mime.MimeType]webrtc.PayloadType) { configureSenderCodecs( params.transceiver, params.enabledCodecs, @@ -3083,14 +3105,14 @@ func configureSender(params configureSenderParams) { ) if params.transceiver.Kind() == webrtc.RTPCodecTypeAudio { - configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK) + configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK, offerAudioPT) } } // configure subscriber transceiver for audio stereo and nack // pion doesn't support per transciver codec configuration, so the nack of this session will be disabled // forever once it is first disabled by a transceiver. -func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) { +func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool, offerAudioPT map[mime.MimeType]webrtc.PayloadType) { sender := tr.Sender() if sender == nil { return @@ -3114,12 +3136,65 @@ func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) { } } } + // When answering a subscriber's offer (single peer connection mode), echo + // the payload type the offer assigned for this codec instead of the server's + // MediaEngine payload type. Otherwise the answer can advertise e.g. Opus on a + // PT that was never offered, which Firefox rejects (received packets decode to + // 0 samples / silence). The forwarded RTP already uses the offered PT. + if len(offerAudioPT) > 0 { + if pt, ok := offerAudioPT[mime.NormalizeMimeType(c.MimeType)]; ok { + c.PayloadType = pt + } + } configCodecs = append(configCodecs, c) } tr.SetCodecPreferences(configCodecs) } +// offerAudioPayloadTypes returns mime type -> payload type for the audio codecs +// in a remote offer, so the subscriber answer can echo the offered payload types +// (RFC 3264 6.1). The caller parses the offer before SetRemoteDescription, so this +// does not race with pion's use of the same description. +func offerAudioPayloadTypes(parsed *sdp.SessionDescription) map[mime.MimeType]webrtc.PayloadType { + if parsed == nil { + return nil + } + out := map[mime.MimeType]webrtc.PayloadType{} + for _, md := range parsed.MediaDescriptions { + if !strings.EqualFold(md.MediaName.Media, "audio") { + continue + } + for _, a := range md.Attributes { + if a.Key != "rtpmap" { + continue + } + // value e.g. "109 opus/48000/2" + fields := strings.Fields(a.Value) + if len(fields) < 2 { + continue + } + pt, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + codecName := fields[1] + if i := strings.Index(codecName, "/"); i >= 0 { + codecName = codecName[:i] + } + mt := mime.NormalizeMimeTypeCodec(codecName).ToMimeType() + if mt == mime.MimeTypeUnknown { + continue + } + out[mt] = webrtc.PayloadType(pt) + } + } + if len(out) == 0 { + return nil + } + return out +} + // In single peer connection mode, set up enebled codecs for sender. // The config provides config of direction. // For publisher peer connection those are publish enabled codecs diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index ab328d7c5..3c0ea425e 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -617,7 +617,7 @@ func TestConfigureAudioTransceiver(t *testing.T) { tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) require.NoError(t, err) - configureSenderAudio(tr, testcase.stereo, testcase.nack) + configureSenderAudio(tr, testcase.stereo, testcase.nack, nil) codecs := tr.Sender().GetParameters().Codecs for _, codec := range codecs { if mime.IsMimeTypeStringOpus(codec.MimeType) { @@ -636,6 +636,32 @@ func TestConfigureAudioTransceiver(t *testing.T) { } } +// When answering a subscriber offer, the sender's audio payload type must echo +// the payload type the offer assigned (RFC 3264), otherwise Firefox decodes no +// audio. See https://github.com/livekit/livekit/issues/4599. +func TestConfigureAudioTransceiverEchoesOfferPayloadType(t *testing.T) { + var me webrtc.MediaEngine + registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false) + pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) + require.NoError(t, err) + + // offer mapped Opus to a payload type different from the server MediaEngine's. + const offeredOpusPT = webrtc.PayloadType(109) + configureSenderAudio(tr, false, true, map[mime.MimeType]webrtc.PayloadType{mime.MimeTypeOpus: offeredOpusPT}) + + var found bool + for _, codec := range tr.Sender().GetParameters().Codecs { + if mime.IsMimeTypeStringOpus(codec.MimeType) { + require.Equal(t, offeredOpusPT, codec.PayloadType) + found = true + } + } + require.True(t, found, "opus codec must be present in sender preferences") +} + // In single-PC mode the publisher PC carries both publish and subscribe // directions. If the MediaEngine were built only from the publish codec list, // the SDP offer would not advertise some codecs in the m-section even though diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 528cd72c7..886217d7f 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -227,6 +227,10 @@ func (t *TransportManager) HasPublisherEverConnected() bool { return t.publisher.HasEverConnected() } +func (t *TransportManager) PublisherFirstConnectedAt() time.Time { + return t.publisher.FirstConnectedAt() +} + func (t *TransportManager) IsPublisherEstablished() bool { return t.publisher.IsEstablished() } @@ -267,6 +271,14 @@ func (t *TransportManager) HasSubscriberEverConnected() bool { } } +func (t *TransportManager) SubscriberFirstConnectedAt() time.Time { + if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection { + return t.publisher.FirstConnectedAt() + } else { + return t.subscriber.FirstConnectedAt() + } +} + func (t *TransportManager) AddTrackLocal( trackLocal webrtc.TrackLocal, params types.AddTrackParams, diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index ec10fb396..5d0c702ed 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -355,6 +355,9 @@ type Participant interface { HandleReceivedDataTrackMessage([]byte, *datatrack.Packet, int64) GetParticipantListener() ParticipantListener + + AddDataBlob(dataBlob *livekit.DataBlob) + GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob } // ------------------------------------------------------- @@ -562,6 +565,9 @@ type LocalParticipant interface { HandlePublishDataTrackRequest(*livekit.PublishDataTrackRequest) HandleUnpublishDataTrackRequest(*livekit.UnpublishDataTrackRequest) HandleUpdateDataSubscription(*livekit.UpdateDataSubscription) + HandleStoreDataBlobRequest(*livekit.StoreDataBlobRequest) + HandleGetDataBlobRequest(*livekit.GetDataBlobRequest) + ProcessGetDataBlobRequest(*livekit.GetDataBlobRequest, Participant) HandleSignalMessage(msg proto.Message) error @@ -572,6 +578,8 @@ type LocalParticipant interface { ClearParticipantListener() GetNextSubscribedDataTrackHandle() uint16 + + GetAllDataBlob() []*livekit.DataBlob } // --------------------------------------------- @@ -621,6 +629,8 @@ type LocalParticipantListener interface { ) OnUpdateSubscriptionPermission(LocalParticipant, *livekit.SubscriptionPermission) error OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription) + OnStoreDataBlob(LocalParticipant, *livekit.DataBlob) + OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest) OnSyncState(LocalParticipant, *livekit.SyncState) error OnSimulateScenario(LocalParticipant, *livekit.SimulateScenario) error OnLeave(LocalParticipant, ParticipantCloseReason) @@ -652,6 +662,10 @@ func (*NullLocalParticipantListener) OnUpdateSubscriptionPermission(LocalPartici } func (*NullLocalParticipantListener) OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription) { } +func (*NullLocalParticipantListener) OnStoreDataBlob(LocalParticipant, *livekit.DataBlob) { +} +func (*NullLocalParticipantListener) OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest) { +} func (*NullLocalParticipantListener) OnSyncState(LocalParticipant, *livekit.SyncState) error { return nil } diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index 9570a28c2..d4198c2de 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -33,6 +33,11 @@ type FakeLocalParticipant struct { activeAtReturnsOnCall map[int]struct { result1 time.Time } + AddDataBlobStub func(*livekit.DataBlob) + addDataBlobMutex sync.RWMutex + addDataBlobArgsForCall []struct { + arg1 *livekit.DataBlob + } AddOnCloseStub func(string, func(types.LocalParticipant)) addOnCloseMutex sync.RWMutex addOnCloseArgsForCall []struct { @@ -216,6 +221,16 @@ type FakeLocalParticipant struct { getAdaptiveStreamReturnsOnCall map[int]struct { result1 bool } + GetAllDataBlobStub func() []*livekit.DataBlob + getAllDataBlobMutex sync.RWMutex + getAllDataBlobArgsForCall []struct { + } + getAllDataBlobReturns struct { + result1 []*livekit.DataBlob + } + getAllDataBlobReturnsOnCall map[int]struct { + result1 []*livekit.DataBlob + } GetAnswerStub func() (webrtc.SessionDescription, uint32, error) getAnswerMutex sync.RWMutex getAnswerArgsForCall []struct { @@ -305,6 +320,17 @@ type FakeLocalParticipant struct { getCountryReturnsOnCall map[int]struct { result1 string } + GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob + getDataBlobMutex sync.RWMutex + getDataBlobArgsForCall []struct { + arg1 *livekit.DataBlobKey + } + getDataBlobReturns struct { + result1 *livekit.DataBlob + } + getDataBlobReturnsOnCall map[int]struct { + result1 *livekit.DataBlob + } GetDataTrackTransportStub func() types.DataTrackTransport getDataTrackTransportMutex sync.RWMutex getDataTrackTransportArgsForCall []struct { @@ -576,6 +602,11 @@ type FakeLocalParticipant struct { handleAnswerArgsForCall []struct { arg1 *livekit.SessionDescription } + HandleGetDataBlobRequestStub func(*livekit.GetDataBlobRequest) + handleGetDataBlobRequestMutex sync.RWMutex + handleGetDataBlobRequestArgsForCall []struct { + arg1 *livekit.GetDataBlobRequest + } HandleICERestartSDPFragmentStub func(string) (string, error) handleICERestartSDPFragmentMutex sync.RWMutex handleICERestartSDPFragmentArgsForCall []struct { @@ -689,6 +720,11 @@ type FakeLocalParticipant struct { handleSimulateScenarioReturnsOnCall map[int]struct { result1 error } + HandleStoreDataBlobRequestStub func(*livekit.StoreDataBlobRequest) + handleStoreDataBlobRequestMutex sync.RWMutex + handleStoreDataBlobRequestArgsForCall []struct { + arg1 *livekit.StoreDataBlobRequest + } HandleSyncStateStub func(*livekit.SyncState) error handleSyncStateMutex sync.RWMutex handleSyncStateArgsForCall []struct { @@ -986,6 +1022,12 @@ type FakeLocalParticipant struct { arg2 chan string arg3 chan error } + ProcessGetDataBlobRequestStub func(*livekit.GetDataBlobRequest, types.Participant) + processGetDataBlobRequestMutex sync.RWMutex + processGetDataBlobRequestArgsForCall []struct { + arg1 *livekit.GetDataBlobRequest + arg2 types.Participant + } ProtocolVersionStub func() types.ProtocolVersion protocolVersionMutex sync.RWMutex protocolVersionArgsForCall []struct { @@ -1594,6 +1636,38 @@ func (fake *FakeLocalParticipant) ActiveAtReturnsOnCall(i int, result1 time.Time }{result1} } +func (fake *FakeLocalParticipant) AddDataBlob(arg1 *livekit.DataBlob) { + fake.addDataBlobMutex.Lock() + fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct { + arg1 *livekit.DataBlob + }{arg1}) + stub := fake.AddDataBlobStub + fake.recordInvocation("AddDataBlob", []interface{}{arg1}) + fake.addDataBlobMutex.Unlock() + if stub != nil { + fake.AddDataBlobStub(arg1) + } +} + +func (fake *FakeLocalParticipant) AddDataBlobCallCount() int { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + return len(fake.addDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) { + fake.addDataBlobMutex.Lock() + defer fake.addDataBlobMutex.Unlock() + fake.AddDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + argsForCall := fake.addDataBlobArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) AddOnClose(arg1 string, arg2 func(types.LocalParticipant)) { fake.addOnCloseMutex.Lock() fake.addOnCloseArgsForCall = append(fake.addOnCloseArgsForCall, struct { @@ -2539,6 +2613,59 @@ func (fake *FakeLocalParticipant) GetAdaptiveStreamReturnsOnCall(i int, result1 }{result1} } +func (fake *FakeLocalParticipant) GetAllDataBlob() []*livekit.DataBlob { + fake.getAllDataBlobMutex.Lock() + ret, specificReturn := fake.getAllDataBlobReturnsOnCall[len(fake.getAllDataBlobArgsForCall)] + fake.getAllDataBlobArgsForCall = append(fake.getAllDataBlobArgsForCall, struct { + }{}) + stub := fake.GetAllDataBlobStub + fakeReturns := fake.getAllDataBlobReturns + fake.recordInvocation("GetAllDataBlob", []interface{}{}) + fake.getAllDataBlobMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetAllDataBlobCallCount() int { + fake.getAllDataBlobMutex.RLock() + defer fake.getAllDataBlobMutex.RUnlock() + return len(fake.getAllDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) GetAllDataBlobCalls(stub func() []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) GetAllDataBlobReturns(result1 []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = nil + fake.getAllDataBlobReturns = struct { + result1 []*livekit.DataBlob + }{result1} +} + +func (fake *FakeLocalParticipant) GetAllDataBlobReturnsOnCall(i int, result1 []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = nil + if fake.getAllDataBlobReturnsOnCall == nil { + fake.getAllDataBlobReturnsOnCall = make(map[int]struct { + result1 []*livekit.DataBlob + }) + } + fake.getAllDataBlobReturnsOnCall[i] = struct { + result1 []*livekit.DataBlob + }{result1} +} + func (fake *FakeLocalParticipant) GetAnswer() (webrtc.SessionDescription, uint32, error) { fake.getAnswerMutex.Lock() ret, specificReturn := fake.getAnswerReturnsOnCall[len(fake.getAnswerArgsForCall)] @@ -2983,6 +3110,67 @@ func (fake *FakeLocalParticipant) GetCountryReturnsOnCall(i int, result1 string) }{result1} } +func (fake *FakeLocalParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob { + fake.getDataBlobMutex.Lock() + ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)] + fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct { + arg1 *livekit.DataBlobKey + }{arg1}) + stub := fake.GetDataBlobStub + fakeReturns := fake.getDataBlobReturns + fake.recordInvocation("GetDataBlob", []interface{}{arg1}) + fake.getDataBlobMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetDataBlobCallCount() int { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + return len(fake.getDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + argsForCall := fake.getDataBlobArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeLocalParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + fake.getDataBlobReturns = struct { + result1 *livekit.DataBlob + }{result1} +} + +func (fake *FakeLocalParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + if fake.getDataBlobReturnsOnCall == nil { + fake.getDataBlobReturnsOnCall = make(map[int]struct { + result1 *livekit.DataBlob + }) + } + fake.getDataBlobReturnsOnCall[i] = struct { + result1 *livekit.DataBlob + }{result1} +} + func (fake *FakeLocalParticipant) GetDataTrackTransport() types.DataTrackTransport { fake.getDataTrackTransportMutex.Lock() ret, specificReturn := fake.getDataTrackTransportReturnsOnCall[len(fake.getDataTrackTransportArgsForCall)] @@ -4428,6 +4616,38 @@ func (fake *FakeLocalParticipant) HandleAnswerArgsForCall(i int) *livekit.Sessio return argsForCall.arg1 } +func (fake *FakeLocalParticipant) HandleGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest) { + fake.handleGetDataBlobRequestMutex.Lock() + fake.handleGetDataBlobRequestArgsForCall = append(fake.handleGetDataBlobRequestArgsForCall, struct { + arg1 *livekit.GetDataBlobRequest + }{arg1}) + stub := fake.HandleGetDataBlobRequestStub + fake.recordInvocation("HandleGetDataBlobRequest", []interface{}{arg1}) + fake.handleGetDataBlobRequestMutex.Unlock() + if stub != nil { + fake.HandleGetDataBlobRequestStub(arg1) + } +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCallCount() int { + fake.handleGetDataBlobRequestMutex.RLock() + defer fake.handleGetDataBlobRequestMutex.RUnlock() + return len(fake.handleGetDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest)) { + fake.handleGetDataBlobRequestMutex.Lock() + defer fake.handleGetDataBlobRequestMutex.Unlock() + fake.HandleGetDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestArgsForCall(i int) *livekit.GetDataBlobRequest { + fake.handleGetDataBlobRequestMutex.RLock() + defer fake.handleGetDataBlobRequestMutex.RUnlock() + argsForCall := fake.handleGetDataBlobRequestArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) HandleICERestartSDPFragment(arg1 string) (string, error) { fake.handleICERestartSDPFragmentMutex.Lock() ret, specificReturn := fake.handleICERestartSDPFragmentReturnsOnCall[len(fake.handleICERestartSDPFragmentArgsForCall)] @@ -5052,6 +5272,38 @@ func (fake *FakeLocalParticipant) HandleSimulateScenarioReturnsOnCall(i int, res }{result1} } +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequest(arg1 *livekit.StoreDataBlobRequest) { + fake.handleStoreDataBlobRequestMutex.Lock() + fake.handleStoreDataBlobRequestArgsForCall = append(fake.handleStoreDataBlobRequestArgsForCall, struct { + arg1 *livekit.StoreDataBlobRequest + }{arg1}) + stub := fake.HandleStoreDataBlobRequestStub + fake.recordInvocation("HandleStoreDataBlobRequest", []interface{}{arg1}) + fake.handleStoreDataBlobRequestMutex.Unlock() + if stub != nil { + fake.HandleStoreDataBlobRequestStub(arg1) + } +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCallCount() int { + fake.handleStoreDataBlobRequestMutex.RLock() + defer fake.handleStoreDataBlobRequestMutex.RUnlock() + return len(fake.handleStoreDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCalls(stub func(*livekit.StoreDataBlobRequest)) { + fake.handleStoreDataBlobRequestMutex.Lock() + defer fake.handleStoreDataBlobRequestMutex.Unlock() + fake.HandleStoreDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestArgsForCall(i int) *livekit.StoreDataBlobRequest { + fake.handleStoreDataBlobRequestMutex.RLock() + defer fake.handleStoreDataBlobRequestMutex.RUnlock() + argsForCall := fake.handleStoreDataBlobRequestArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) HandleSyncState(arg1 *livekit.SyncState) error { fake.handleSyncStateMutex.Lock() ret, specificReturn := fake.handleSyncStateReturnsOnCall[len(fake.handleSyncStateArgsForCall)] @@ -6680,6 +6932,39 @@ func (fake *FakeLocalParticipant) PerformRpcArgsForCall(i int) (*livekit.Perform return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest, arg2 types.Participant) { + fake.processGetDataBlobRequestMutex.Lock() + fake.processGetDataBlobRequestArgsForCall = append(fake.processGetDataBlobRequestArgsForCall, struct { + arg1 *livekit.GetDataBlobRequest + arg2 types.Participant + }{arg1, arg2}) + stub := fake.ProcessGetDataBlobRequestStub + fake.recordInvocation("ProcessGetDataBlobRequest", []interface{}{arg1, arg2}) + fake.processGetDataBlobRequestMutex.Unlock() + if stub != nil { + fake.ProcessGetDataBlobRequestStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCallCount() int { + fake.processGetDataBlobRequestMutex.RLock() + defer fake.processGetDataBlobRequestMutex.RUnlock() + return len(fake.processGetDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest, types.Participant)) { + fake.processGetDataBlobRequestMutex.Lock() + defer fake.processGetDataBlobRequestMutex.Unlock() + fake.ProcessGetDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestArgsForCall(i int) (*livekit.GetDataBlobRequest, types.Participant) { + fake.processGetDataBlobRequestMutex.RLock() + defer fake.processGetDataBlobRequestMutex.RUnlock() + argsForCall := fake.processGetDataBlobRequestArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipant) ProtocolVersion() types.ProtocolVersion { fake.protocolVersionMutex.Lock() ret, specificReturn := fake.protocolVersionReturnsOnCall[len(fake.protocolVersionArgsForCall)] diff --git a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go index 4cd0fef83..4c0a3a07a 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go @@ -42,6 +42,12 @@ type FakeLocalParticipantListener struct { arg1 types.Participant arg2 types.DataTrack } + OnGetDataBlobStub func(types.LocalParticipant, *livekit.GetDataBlobRequest) + onGetDataBlobMutex sync.RWMutex + onGetDataBlobArgsForCall []struct { + arg1 types.LocalParticipant + arg2 *livekit.GetDataBlobRequest + } OnLeaveStub func(types.LocalParticipant, types.ParticipantCloseReason) onLeaveMutex sync.RWMutex onLeaveArgsForCall []struct { @@ -82,6 +88,12 @@ type FakeLocalParticipantListener struct { onStateChangeArgsForCall []struct { arg1 types.LocalParticipant } + OnStoreDataBlobStub func(types.LocalParticipant, *livekit.DataBlob) + onStoreDataBlobMutex sync.RWMutex + onStoreDataBlobArgsForCall []struct { + arg1 types.LocalParticipant + arg2 *livekit.DataBlob + } OnSubscribeStatusChangedStub func(types.LocalParticipant, livekit.ParticipantID, bool) onSubscribeStatusChangedMutex sync.RWMutex onSubscribeStatusChangedArgsForCall []struct { @@ -331,6 +343,39 @@ func (fake *FakeLocalParticipantListener) OnDataTrackUnpublishedArgsForCall(i in return argsForCall.arg1, argsForCall.arg2 } +func (fake *FakeLocalParticipantListener) OnGetDataBlob(arg1 types.LocalParticipant, arg2 *livekit.GetDataBlobRequest) { + fake.onGetDataBlobMutex.Lock() + fake.onGetDataBlobArgsForCall = append(fake.onGetDataBlobArgsForCall, struct { + arg1 types.LocalParticipant + arg2 *livekit.GetDataBlobRequest + }{arg1, arg2}) + stub := fake.OnGetDataBlobStub + fake.recordInvocation("OnGetDataBlob", []interface{}{arg1, arg2}) + fake.onGetDataBlobMutex.Unlock() + if stub != nil { + fake.OnGetDataBlobStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobCallCount() int { + fake.onGetDataBlobMutex.RLock() + defer fake.onGetDataBlobMutex.RUnlock() + return len(fake.onGetDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobCalls(stub func(types.LocalParticipant, *livekit.GetDataBlobRequest)) { + fake.onGetDataBlobMutex.Lock() + defer fake.onGetDataBlobMutex.Unlock() + fake.OnGetDataBlobStub = stub +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.GetDataBlobRequest) { + fake.onGetDataBlobMutex.RLock() + defer fake.onGetDataBlobMutex.RUnlock() + argsForCall := fake.onGetDataBlobArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipantListener) OnLeave(arg1 types.LocalParticipant, arg2 types.ParticipantCloseReason) { fake.onLeaveMutex.Lock() fake.onLeaveArgsForCall = append(fake.onLeaveArgsForCall, struct { @@ -556,6 +601,39 @@ func (fake *FakeLocalParticipantListener) OnStateChangeArgsForCall(i int) types. return argsForCall.arg1 } +func (fake *FakeLocalParticipantListener) OnStoreDataBlob(arg1 types.LocalParticipant, arg2 *livekit.DataBlob) { + fake.onStoreDataBlobMutex.Lock() + fake.onStoreDataBlobArgsForCall = append(fake.onStoreDataBlobArgsForCall, struct { + arg1 types.LocalParticipant + arg2 *livekit.DataBlob + }{arg1, arg2}) + stub := fake.OnStoreDataBlobStub + fake.recordInvocation("OnStoreDataBlob", []interface{}{arg1, arg2}) + fake.onStoreDataBlobMutex.Unlock() + if stub != nil { + fake.OnStoreDataBlobStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobCallCount() int { + fake.onStoreDataBlobMutex.RLock() + defer fake.onStoreDataBlobMutex.RUnlock() + return len(fake.onStoreDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobCalls(stub func(types.LocalParticipant, *livekit.DataBlob)) { + fake.onStoreDataBlobMutex.Lock() + defer fake.onStoreDataBlobMutex.Unlock() + fake.OnStoreDataBlobStub = stub +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.DataBlob) { + fake.onStoreDataBlobMutex.RLock() + defer fake.onStoreDataBlobMutex.RUnlock() + argsForCall := fake.onStoreDataBlobArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipantListener) OnSubscribeStatusChanged(arg1 types.LocalParticipant, arg2 livekit.ParticipantID, arg3 bool) { fake.onSubscribeStatusChangedMutex.Lock() fake.onSubscribeStatusChangedArgsForCall = append(fake.onSubscribeStatusChangedArgsForCall, struct { diff --git a/pkg/rtc/types/typesfakes/fake_participant.go b/pkg/rtc/types/typesfakes/fake_participant.go index 19498c9c3..652c8bf14 100644 --- a/pkg/rtc/types/typesfakes/fake_participant.go +++ b/pkg/rtc/types/typesfakes/fake_participant.go @@ -13,6 +13,11 @@ import ( ) type FakeParticipant struct { + AddDataBlobStub func(*livekit.DataBlob) + addDataBlobMutex sync.RWMutex + addDataBlobArgsForCall []struct { + arg1 *livekit.DataBlob + } CanSkipBroadcastStub func() bool canSkipBroadcastMutex sync.RWMutex canSkipBroadcastArgsForCall []struct { @@ -78,6 +83,17 @@ type FakeParticipant struct { result1 float64 result2 bool } + GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob + getDataBlobMutex sync.RWMutex + getDataBlobArgsForCall []struct { + arg1 *livekit.DataBlobKey + } + getDataBlobReturns struct { + result1 *livekit.DataBlob + } + getDataBlobReturnsOnCall map[int]struct { + result1 *livekit.DataBlob + } GetLoggerStub func() logger.Logger getLoggerMutex sync.RWMutex getLoggerArgsForCall []struct { @@ -361,6 +377,38 @@ type FakeParticipant struct { invocationsMutex sync.RWMutex } +func (fake *FakeParticipant) AddDataBlob(arg1 *livekit.DataBlob) { + fake.addDataBlobMutex.Lock() + fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct { + arg1 *livekit.DataBlob + }{arg1}) + stub := fake.AddDataBlobStub + fake.recordInvocation("AddDataBlob", []interface{}{arg1}) + fake.addDataBlobMutex.Unlock() + if stub != nil { + fake.AddDataBlobStub(arg1) + } +} + +func (fake *FakeParticipant) AddDataBlobCallCount() int { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + return len(fake.addDataBlobArgsForCall) +} + +func (fake *FakeParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) { + fake.addDataBlobMutex.Lock() + defer fake.addDataBlobMutex.Unlock() + fake.AddDataBlobStub = stub +} + +func (fake *FakeParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + argsForCall := fake.addDataBlobArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeParticipant) CanSkipBroadcast() bool { fake.canSkipBroadcastMutex.Lock() ret, specificReturn := fake.canSkipBroadcastReturnsOnCall[len(fake.canSkipBroadcastArgsForCall)] @@ -692,6 +740,67 @@ func (fake *FakeParticipant) GetAudioLevelReturnsOnCall(i int, result1 float64, }{result1, result2} } +func (fake *FakeParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob { + fake.getDataBlobMutex.Lock() + ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)] + fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct { + arg1 *livekit.DataBlobKey + }{arg1}) + stub := fake.GetDataBlobStub + fakeReturns := fake.getDataBlobReturns + fake.recordInvocation("GetDataBlob", []interface{}{arg1}) + fake.getDataBlobMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeParticipant) GetDataBlobCallCount() int { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + return len(fake.getDataBlobArgsForCall) +} + +func (fake *FakeParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = stub +} + +func (fake *FakeParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + argsForCall := fake.getDataBlobArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + fake.getDataBlobReturns = struct { + result1 *livekit.DataBlob + }{result1} +} + +func (fake *FakeParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + if fake.getDataBlobReturnsOnCall == nil { + fake.getDataBlobReturnsOnCall = make(map[int]struct { + result1 *livekit.DataBlob + }) + } + fake.getDataBlobReturnsOnCall[i] = struct { + result1 *livekit.DataBlob + }{result1} +} + func (fake *FakeParticipant) GetLogger() logger.Logger { fake.getLoggerMutex.Lock() ret, specificReturn := fake.getLoggerReturnsOnCall[len(fake.getLoggerArgsForCall)] diff --git a/pkg/service/agentservice.go b/pkg/service/agentservice.go index 8481a1e3d..5f95f0722 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agentservice.go @@ -485,19 +485,29 @@ func (h *AgentHandler) CheckEnabled(ctx context.Context, req *rpc.CheckEnabledRe }, nil } -func (h *AgentHandler) DrainConnections(interval time.Duration) { - // jitter drain start - time.Sleep(time.Duration(rand.Int63n(int64(interval)))) +func (h *AgentHandler) DrainConnections(interval time.Duration, force bool) { + if !force { + // jitter drain start + time.Sleep(time.Duration(rand.Int63n(int64(interval)))) - t := time.NewTicker(interval) - defer t.Stop() + t := time.NewTicker(interval) + defer t.Stop() - h.mu.Lock() - defer h.mu.Unlock() + h.mu.Lock() + defer h.mu.Unlock() - for _, w := range h.workers { - w.Close() - <-t.C + for _, w := range h.workers { + w.Close() + <-t.C + } + } else { + // drain as quickly as possible when forced + h.mu.Lock() + defer h.mu.Unlock() + + for _, w := range h.workers { + w.Close() + } } } diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 13ca67493..ce850df6e 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -538,6 +538,7 @@ func (r *RoomManager) StartSession( FireOnTrackBySdp: true, UseSinglePeerConnection: pi.UseSinglePeerConnection, EnableDataTracks: r.config.EnableDataTracks, + EnableParticipantDataBlob: r.config.EnableParticipantDataBlob, EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection, }) if err != nil { diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index d4f83e7cb..84082f355 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -401,9 +401,6 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ return } - prometheus.IncrementParticipantJoin(1) - joinDuration = time.Since(startedAt) - pLogger = pLogger.WithValues("connID", cr.ConnectionID) if !pi.Reconnect && initialResponse.GetJoin() != nil { joinRoomID := livekit.RoomID(initialResponse.GetJoin().GetRoom().GetSid()) @@ -445,6 +442,7 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ // upgrade only once the basics are good to go conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { + prometheus.IncrementParticipantJoinUpgradeFail(1) resolveLogger(true) HandleError(w, r, http.StatusInternalServerError, err, getLoggerFields()...) return @@ -465,12 +463,17 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ pLogger.Debugw("sending initial response", "response", logger.Proto(initialResponse)) count, err := sigConn.WriteResponse(initialResponse) if err != nil { + prometheus.IncrementParticipantJoinWriteInitialResponseFail(1) resolveLogger(true) pLogger.Warnw("could not write initial response", err) return } signalStats.AddBytes(uint64(count), true) + prometheus.IncrementParticipantJoin(1) + joinDuration = time.Since(startedAt) + prometheus.RecordSessionJoinLatency(int(pi.Client.GetProtocol()), joinDuration) + pLogger.Debugw( "new client WS connected", "reconnect", pi.Reconnect, @@ -625,20 +628,27 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ } } -func (s *RTCService) DrainConnections(interval time.Duration) { +func (s *RTCService) DrainConnections(interval time.Duration, force bool) { s.mu.Lock() conns := maps.Clone(s.connections) s.mu.Unlock() - // jitter drain start - time.Sleep(time.Duration(rand.Int63n(int64(interval)))) + if !force { + // jitter drain start + time.Sleep(time.Duration(rand.Int63n(int64(interval)))) - t := time.NewTicker(interval) - defer t.Stop() + t := time.NewTicker(interval) + defer t.Stop() - for c := range conns { - _ = c.Close() - <-t.C + for c := range conns { + _ = c.Close() + <-t.C + } + } else { + // drain as quickly as possible when forced + for c := range conns { + _ = c.Close() + } } } diff --git a/pkg/sfu/datachannel/datachannel_writer_test.go b/pkg/sfu/datachannel/datachannel_writer_test.go index 9b99b8929..3bdcefbf4 100644 --- a/pkg/sfu/datachannel/datachannel_writer_test.go +++ b/pkg/sfu/datachannel/datachannel_writer_test.go @@ -2,10 +2,11 @@ package datachannel import ( "context" - "sync/atomic" "testing" "time" + "go.uber.org/atomic" + "github.com/pion/datachannel" "github.com/pion/transport/v4/deadline" "github.com/stretchr/testify/require" diff --git a/pkg/telemetry/prometheus/node.go b/pkg/telemetry/prometheus/node.go index c3a3fc3ca..0214d8e8e 100644 --- a/pkg/telemetry/prometheus/node.go +++ b/pkg/telemetry/prometheus/node.go @@ -149,43 +149,41 @@ func GetNodeStats(nodeStartedAt int64, prevStats []*livekit.NodeStats, rateInter promSysPacketGauge.WithLabelValues("dropped").Set(float64(sysDroppedPackets - sysDroppedPacketsStart)) stats := &livekit.NodeStats{ - StartedAt: nodeStartedAt, - UpdatedAt: time.Now().Unix(), - NumRooms: roomCurrent.Load(), - NumClients: participantCurrent.Load(), - NumTracksIn: trackPublishedCurrent.Load(), - NumTracksOut: trackSubscribedCurrent.Load(), - NumTrackPublishAttempts: trackPublishAttempts.Load(), - NumTrackPublishSuccess: trackPublishSuccess.Load(), - NumTrackPublishCancels: trackPublishCancels.Load(), - NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(), - NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(), - NumTrackSubscribeCancels: trackSubscribeCancels.Load(), - BytesIn: bytesIn.Load(), - BytesOut: bytesOut.Load(), - PacketsIn: packetsIn.Load(), - PacketsOut: packetsOut.Load(), - RetransmitBytesOut: retransmitBytes.Load(), - RetransmitPacketsOut: retransmitPackets.Load(), - NackTotal: nackTotal.Load(), - ParticipantSignalConnected: participantSignalConnected.Load(), - ParticipantSignalFailed: participantSignalFailed.Load(), - ParticipantSignalValidationFailed: participantSignalValidationFailed.Load(), - ParticipantRtcInit: participantRTCInit.Load(), - ParticipantRtcConnected: participantRTCConnected.Load(), - ParticipantRtcCanceled: participantRTCCanceled.Load(), - ParticipantRtcActive: participantRTCActive.Load(), - ForwardLatency: forwardLatency.Load(), - ForwardJitter: forwardJitter.Load(), - NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer - CpuLoad: float32(cpuStats.GetCPULoad()), - MemoryTotal: memTotal, - MemoryUsed: memUsed, - LoadAvgLast1Min: float32(loadAvg.Loadavg1), - LoadAvgLast5Min: float32(loadAvg.Loadavg5), - LoadAvgLast15Min: float32(loadAvg.Loadavg15), - SysPacketsOut: sysPackets, - SysPacketsDropped: sysDroppedPackets, + StartedAt: nodeStartedAt, + UpdatedAt: time.Now().Unix(), + NumRooms: roomCurrent.Load(), + NumClients: participantCurrent.Load(), + NumTracksIn: trackPublishedCurrent.Load(), + NumTracksOut: trackSubscribedCurrent.Load(), + NumTrackPublishAttempts: trackPublishAttempts.Load(), + NumTrackPublishSuccess: trackPublishSuccess.Load(), + NumTrackPublishCancels: trackPublishCancels.Load(), + NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(), + NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(), + NumTrackSubscribeCancels: trackSubscribeCancels.Load(), + BytesIn: bytesIn.Load(), + BytesOut: bytesOut.Load(), + PacketsIn: packetsIn.Load(), + PacketsOut: packetsOut.Load(), + RetransmitBytesOut: retransmitBytes.Load(), + RetransmitPacketsOut: retransmitPackets.Load(), + NackTotal: nackTotal.Load(), + ParticipantSignalConnected: participantSignalConnected.Load(), + ParticipantRtcInit: participantRTCInit.Load(), + ParticipantRtcConnected: participantRTCConnected.Load(), + ParticipantRtcCanceled: participantRTCCanceled.Load(), + ParticipantRtcActive: participantRTCActive.Load(), + ForwardLatency: forwardLatency.Load(), + ForwardJitter: forwardJitter.Load(), + NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer + CpuLoad: float32(cpuStats.GetCPULoad()), + MemoryTotal: memTotal, + MemoryUsed: memUsed, + LoadAvgLast1Min: float32(loadAvg.Loadavg1), + LoadAvgLast5Min: float32(loadAvg.Loadavg5), + LoadAvgLast15Min: float32(loadAvg.Loadavg15), + SysPacketsOut: sysPackets, + SysPacketsDropped: sysDroppedPackets, } for _, rateInterval := range rateIntervals { diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index a9f6684e1..c69afdaaa 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -36,22 +36,20 @@ const ( ) var ( - bytesIn atomic.Uint64 - bytesOut atomic.Uint64 - packetsIn atomic.Uint64 - packetsOut atomic.Uint64 - nackTotal atomic.Uint64 - retransmitBytes atomic.Uint64 - retransmitPackets atomic.Uint64 - participantSignalConnected atomic.Uint64 - participantSignalFailed atomic.Uint64 - participantSignalValidationFailed atomic.Uint64 - participantRTCConnected atomic.Uint64 - participantRTCInit atomic.Uint64 - participantRTCCanceled atomic.Uint64 - participantRTCActive atomic.Uint64 - forwardLatency atomic.Uint32 - forwardJitter atomic.Uint32 + bytesIn atomic.Uint64 + bytesOut atomic.Uint64 + packetsIn atomic.Uint64 + packetsOut atomic.Uint64 + nackTotal atomic.Uint64 + retransmitBytes atomic.Uint64 + retransmitPackets atomic.Uint64 + participantSignalConnected atomic.Uint64 + participantRTCConnected atomic.Uint64 + participantRTCInit atomic.Uint64 + participantRTCCanceled atomic.Uint64 + participantRTCActive atomic.Uint64 + forwardLatency atomic.Uint32 + forwardJitter atomic.Uint32 promPacketLabels = []string{"direction", "transmission", "country"} promPacketTotal *prometheus.CounterVec @@ -305,29 +303,39 @@ func IncrementParticipantJoin(join uint32) { func IncrementParticipantJoinFail(fail uint32) { if fail > 0 { - participantSignalFailed.Add(uint64(fail)) promParticipantJoin.WithLabelValues("signal_failed").Add(float64(fail)) } } func IncrementParticipantJoinValidationFail(validationFail uint32) { if validationFail > 0 { - participantSignalValidationFailed.Add(uint64(validationFail)) promParticipantJoin.WithLabelValues("signal_validation_failed").Add(float64(validationFail)) } } -func IncrementParticipantRtcInit(join uint32) { - if join > 0 { - participantRTCInit.Add(uint64(join)) - promParticipantJoin.WithLabelValues("rtc_init").Add(float64(join)) +func IncrementParticipantJoinUpgradeFail(upgradeFail uint32) { + if upgradeFail > 0 { + promParticipantJoin.WithLabelValues("signal_upgrade_failed").Add(float64(upgradeFail)) } } -func IncrementParticipantRtcConnected(join uint32) { - if join > 0 { - participantRTCConnected.Add(uint64(join)) - promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(join)) +func IncrementParticipantJoinWriteInitialResponseFail(writeInitialResponseFail uint32) { + if writeInitialResponseFail > 0 { + promParticipantJoin.WithLabelValues("signal_write_initial_response_failed").Add(float64(writeInitialResponseFail)) + } +} + +func IncrementParticipantRtcInit(init uint32) { + if init > 0 { + participantRTCInit.Add(uint64(init)) + promParticipantJoin.WithLabelValues("rtc_init").Add(float64(init)) + } +} + +func IncrementParticipantRtcConnected(connected uint32) { + if connected > 0 { + participantRTCConnected.Add(uint64(connected)) + promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(connected)) } } @@ -338,10 +346,10 @@ func IncrementParticipantRtcActive(active uint32) { } } -func IncrementParticipantRtcCanceled(numCancels uint64) { - if numCancels > 0 { - participantRTCCanceled.Add(numCancels) - promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(numCancels)) +func IncrementParticipantRtcCanceled(canceled uint64) { + if canceled > 0 { + participantRTCCanceled.Add(canceled) + promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(canceled)) } } diff --git a/pkg/telemetry/prometheus/rooms.go b/pkg/telemetry/prometheus/rooms.go index 4001c24d9..d67a2c59d 100644 --- a/pkg/telemetry/prometheus/rooms.go +++ b/pkg/telemetry/prometheus/rooms.go @@ -46,6 +46,7 @@ var ( promTrackSubscribedCurrent *prometheus.GaugeVec promTrackPublishCounter *prometheus.CounterVec promTrackSubscribeCounter *prometheus.CounterVec + promSessionJoinLatency *prometheus.HistogramVec promSessionStartTime *prometheus.HistogramVec promSessionDuration *prometheus.HistogramVec promPubSubTime *prometheus.HistogramVec @@ -99,6 +100,13 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { Name: "subscribe_counter", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, }, []string{"state", "error"}) + promSessionJoinLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: livekitNamespace, + Subsystem: "session", + Name: "join_latency_ms", + ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, + Buckets: prometheus.ExponentialBucketsRange(10, 10000, 15), + }, []string{"protocol_version"}) promSessionStartTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "session", @@ -134,6 +142,7 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { prometheus.MustRegister(promTrackSubscribedCurrent) prometheus.MustRegister(promTrackPublishCounter) prometheus.MustRegister(promTrackSubscribeCounter) + prometheus.MustRegister(promSessionJoinLatency) prometheus.MustRegister(promSessionStartTime) prometheus.MustRegister(promSessionDuration) prometheus.MustRegister(promPubSubTime) @@ -271,6 +280,10 @@ func RecordTrackSubscribeCancels(numCancels int32) { promTrackSubscribeCounter.WithLabelValues("cancel", "").Add(float64(numCancels)) } +func RecordSessionJoinLatency(protocolVersion int, d time.Duration) { + promSessionJoinLatency.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) +} + func RecordSessionStartTime(protocolVersion int, d time.Duration) { promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) } diff --git a/test/integration_helpers.go b/test/integration_helpers.go index 3ccde58d9..367294e46 100644 --- a/test/integration_helpers.go +++ b/test/integration_helpers.go @@ -80,9 +80,13 @@ func setupSingleNodeTest(name string) (*service.LivekitServer, func()) { } func setupMultiNodeTest(name string) (*service.LivekitServer, *service.LivekitServer, func()) { + return setupMultiNodeTestWithConfig(name, nil) +} + +func setupMultiNodeTestWithConfig(name string, configUpdater func(*config.Config)) (*service.LivekitServer, *service.LivekitServer, func()) { logger.Infow("----------------STARTING TEST----------------", "test", name) - s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort) - s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort) + s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort, configUpdater) + s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort, configUpdater) go s1.Start() go s2.Start() @@ -190,7 +194,7 @@ func createSingleNodeServer(configUpdater func(*config.Config)) *service.Livekit return s } -func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer { +func createMultiNodeServer(nodeID string, port uint32, configUpdater func(*config.Config)) *service.LivekitServer { var err error conf, err := config.NewConfig("", true, nil, nil) if err != nil { @@ -202,6 +206,9 @@ func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer { conf.Redis.Address = "localhost:6379" conf.Keys = map[string]string{testApiKey: testApiSecret} conf.EnableDataTracks = true + if configUpdater != nil { + configUpdater(conf) + } currentNode, err := routing.NewLocalNode(conf) if err != nil { diff --git a/test/multinode_test.go b/test/multinode_test.go index 9b4d5acea..21f4294c6 100644 --- a/test/multinode_test.go +++ b/test/multinode_test.go @@ -24,6 +24,7 @@ import ( "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/rtc" "github.com/livekit/livekit-server/pkg/testutils" "github.com/livekit/livekit-server/test/client" @@ -425,3 +426,129 @@ func TestCloseDisconnectedParticipantOnSignalClose(t *testing.T) { }) } } + +func TestMultiNodeDataBlob(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, _, finish := setupMultiNodeTestWithConfig("TestMultiNodeDataBlob", func(c *config.Config) { + c.EnableParticipantDataBlob = true + c.Limit.MaxDataBlobSize = 1024 + }) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + subCapture := &dataBlobCapture{} + + // publisher on node 1, subscriber on node 2 + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &client.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + sub := createRTCClient("sub", secondServerPort, testRTCServicePath, &client.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: subCapture.interceptor(), + }) + waitUntilConnected(t, pub, sub) + defer stopClients(pub, sub) + + // wait for both nodes to see each other so the get request routes correctly + testutils.WithTimeout(t, func() string { + if sub.GetRemoteParticipant(pub.ID()) == nil { + return "sub does not see pub yet" + } + return "" + }) + + key := &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-multinode", + }, + } + contents := []byte("multinode-content") + + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + RequestId: 1, + Blob: &livekit.DataBlob{ + Key: key, + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := pubCapture.takeStoreResponse() + if resp == nil { + return "publisher did not receive store response" + } + if resp.RequestId != 1 { + return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId) + } + if resp.Key == nil { + return "store response missing key" + } + if resp.Key.String() != key.String() { + return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String()) + } + return "" + }) + require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success") + + // subscriber on a different node asks for the blob; the request routes + // across nodes to the publisher. + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := subCapture.takeBlobResponse() + if resp == nil { + return "subscriber did not receive blob response" + } + if resp.Blob == nil { + return "blob response missing blob" + } + if resp.Blob.Key.String() != key.String() { + return fmt.Sprintf("expected data blob key %s, got %s", key.String(), resp.Blob.Key.String()) + } + if string(resp.Blob.Contents) != string(contents) { + return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents) + } + return "" + }) + + // requesting an unknown publisher identity should return NOT_FOUND + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "unknown-publisher", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for unknown publisher" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + }) + } +} diff --git a/test/singlenode_test.go b/test/singlenode_test.go index cf09473f3..6d88f4d8a 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -1526,3 +1526,299 @@ func TestTurnAuthFailure(t *testing.T) { }) } } + +// dataBlobCapture buffers RequestResponse, StoreDataBlobResponse, and GetDataBlobResponse messages +// sent to a test client so they can be asserted on. Other messages flow through to the +// default handler. +type dataBlobCapture struct { + mu sync.Mutex + requestResponses []*livekit.RequestResponse + storeResponses []*livekit.StoreDataBlobResponse + blobResponses []*livekit.GetDataBlobResponse +} + +func (c *dataBlobCapture) interceptor() testclient.SignalResponseInterceptor { + return func(msg *livekit.SignalResponse, next testclient.SignalResponseHandler) error { + switch m := msg.Message.(type) { + case *livekit.SignalResponse_RequestResponse: + c.mu.Lock() + c.requestResponses = append(c.requestResponses, m.RequestResponse) + c.mu.Unlock() + case *livekit.SignalResponse_StoreDataBlobResponse: + c.mu.Lock() + c.storeResponses = append(c.storeResponses, m.StoreDataBlobResponse) + c.mu.Unlock() + case *livekit.SignalResponse_GetDataBlobResponse: + c.mu.Lock() + c.blobResponses = append(c.blobResponses, m.GetDataBlobResponse) + c.mu.Unlock() + } + return next(msg) + } +} + +func (c *dataBlobCapture) takeRequestResponse() *livekit.RequestResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.requestResponses) == 0 { + return nil + } + rr := c.requestResponses[0] + c.requestResponses = c.requestResponses[1:] + return rr +} + +func (c *dataBlobCapture) takeStoreResponse() *livekit.StoreDataBlobResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.storeResponses) == 0 { + return nil + } + sr := c.storeResponses[0] + c.storeResponses = c.storeResponses[1:] + return sr +} + +func (c *dataBlobCapture) takeBlobResponse() *livekit.GetDataBlobResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.blobResponses) == 0 { + return nil + } + sr := c.blobResponses[0] + c.blobResponses = c.blobResponses[1:] + return sr +} + +func (c *dataBlobCapture) requestResponseCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.requestResponses) +} + +func setupDataBlobServer(t *testing.T, name string, enable bool) (*service.LivekitServer, func()) { + logger.Infow("----------------STARTING TEST----------------", "test", name) + s := createSingleNodeServer(func(c *config.Config) { + c.EnableParticipantDataBlob = enable + c.Limit.MaxDataBlobSize = 1024 + }) + go func() { + if err := s.Start(); err != nil { + logger.Errorw("server returned error", err) + } + }() + waitForServerToStart(s) + return s, func() { + s.Stop(true) + logger.Infow("----------------FINISHING TEST----------------", "test", name) + } +} + +func TestSingleNodeDataBlob(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, finish := setupDataBlobServer(t, "TestSingleNodeDataBlob", true) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + subCapture := &dataBlobCapture{} + + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + sub := createRTCClient("sub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: subCapture.interceptor(), + }) + waitUntilConnected(t, pub, sub) + defer stopClients(pub, sub) + + key := &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-1", + }, + } + contents := []byte("definition-bytes") + + // publisher stores a blob + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + RequestId: 1, + Blob: &livekit.DataBlob{ + Key: key, + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := pubCapture.takeStoreResponse() + if resp == nil { + return "publisher did not receive store response" + } + if resp.RequestId != 1 { + return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId) + } + if resp.Key == nil { + return "store response missing key" + } + if resp.Key.String() != key.String() { + return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String()) + } + return "" + }) + require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success") + + // subscriber asks for the blob + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := subCapture.takeBlobResponse() + if resp == nil { + return "subscriber did not receive blob response" + } + if resp.Blob == nil { + return "blob response missing blob" + } + if resp.Blob.Key.String() != key.String() { + return fmt.Sprintf("expected blob key %s, got %s", key.String(), resp.Blob.Key.String()) + } + if string(resp.Blob.Contents) != string(contents) { + return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents) + } + return "" + }) + + // subscriber asks for an unknown blob on a known publisher + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "does-not-exist", + }, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for missing blob" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + + // subscriber asks for a blob on an unknown publisher identity + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "unknown-publisher", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for unknown publisher" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + + // publisher sends an invalid blob (empty key) + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := pubCapture.takeRequestResponse() + if rr == nil { + return "publisher did not receive RequestResponse for invalid define" + } + if rr.Reason != livekit.RequestResponse_INVALID_REQUEST { + return fmt.Sprintf("expected INVALID_REQUEST, got %s", rr.Reason) + } + return "" + }) + }) + } +} + +func TestSingleNodeDataBlobDisabled(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, finish := setupDataBlobServer(t, "TestSingleNodeDataBlobDisabled", false) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + waitUntilConnected(t, pub) + defer stopClients(pub) + + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-1", + }, + }, + Contents: []byte("definition-bytes"), + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := pubCapture.takeRequestResponse() + if rr == nil { + return "publisher did not receive RequestResponse" + } + if rr.Reason != livekit.RequestResponse_NOT_ALLOWED { + return fmt.Sprintf("expected NOT_ALLOWED, got %s", rr.Reason) + } + return "" + }) + }) + } +} diff --git a/version/version.go b/version/version.go index fbc9dc1e2..3f1dbda2b 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.1" +const Version = "1.13.2"