refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
This commit is contained in:
1 parent
0653b7a58a
commit
da47189fc3
291 files changed
+42361
-34024
No files matched your search
@@ -0,0 +1,179 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from src/server/access-session.ts.
|
||||
const (
|
||||
defaultSessionTTLSeconds = 24 * 60 * 60
|
||||
cookieVersion = "v1"
|
||||
)
|
||||
|
||||
var (
|
||||
// TS: /^\d+$/ over the expiry segment.
|
||||
accessExpiresPattern = regexp.MustCompile(`^[0-9]+$`)
|
||||
// TS: /^[A-Za-z0-9_-]{43}$/ — base64url of a 32-byte HMAC, unpadded.
|
||||
accessSignaturePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
|
||||
)
|
||||
|
||||
// siteAccessOptions mirrors SiteAccessOptions in access-session.ts.
|
||||
type siteAccessOptions struct {
|
||||
// Password empty means site access is not required.
|
||||
Password string
|
||||
// Secure selects the __Host- cookie name and appends the Secure attribute.
|
||||
Secure bool
|
||||
// Now returns Unix milliseconds; nil uses the wall clock.
|
||||
Now func() int64
|
||||
// TTLSeconds of 0 stands for TS's absent `ttlSeconds`, i.e. 24 hours.
|
||||
TTLSeconds int
|
||||
}
|
||||
|
||||
// siteAccessGate issues and verifies the stateless site-access cookie.
|
||||
type siteAccessGate struct {
|
||||
password string
|
||||
secure bool
|
||||
now func() int64
|
||||
ttlSeconds int
|
||||
}
|
||||
|
||||
// newSiteAccess mirrors the SiteAccess constructor, including its TTL check.
|
||||
func newSiteAccess(options siteAccessOptions) (*siteAccessGate, error) {
|
||||
ttlSeconds := options.TTLSeconds
|
||||
if ttlSeconds == 0 {
|
||||
ttlSeconds = defaultSessionTTLSeconds
|
||||
}
|
||||
// TS: !Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0.
|
||||
if ttlSeconds < 0 || int64(ttlSeconds) > protocol.MaxSafeInteger {
|
||||
return nil, errors.New("Access session TTL must be a positive integer")
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = func() int64 { return time.Now().UnixMilli() }
|
||||
}
|
||||
return &siteAccessGate{
|
||||
password: options.Password,
|
||||
secure: options.Secure,
|
||||
now: now,
|
||||
ttlSeconds: ttlSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// required reports whether a password is configured.
|
||||
func (a *siteAccessGate) required() bool {
|
||||
return a.password != ""
|
||||
}
|
||||
|
||||
// passwordMatches mirrors passwordMatches: an unset password accepts anything,
|
||||
// a set password needs a non-empty exact match.
|
||||
func (a *siteAccessGate) passwordMatches(value string) bool {
|
||||
return !a.required() || (value != "" && secretMatches(value, a.password))
|
||||
}
|
||||
|
||||
// isAuthenticated mirrors isAuthenticated over a raw Cookie request header.
|
||||
func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool {
|
||||
if !a.required() {
|
||||
return true
|
||||
}
|
||||
value := readCookie(cookieHeader, a.cookieName())
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
// TS: const [version, expiresText, signature, ...extra] = value.split(".")
|
||||
// Fewer than three segments fail the two regexes below (an absent segment
|
||||
// is tested as ""), more than three fill `extra`.
|
||||
segments := strings.Split(value, ".")
|
||||
if len(segments) != 3 ||
|
||||
segments[0] != cookieVersion ||
|
||||
!accessExpiresPattern.MatchString(segments[1]) ||
|
||||
!accessSignaturePattern.MatchString(segments[2]) {
|
||||
return false
|
||||
}
|
||||
// TS: Number(expiresText) then Number.isSafeInteger. The segment is all
|
||||
// digits, so only an out-of-range value can fail here.
|
||||
expiresAt, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil || expiresAt > protocol.MaxSafeInteger {
|
||||
return false
|
||||
}
|
||||
if expiresAt <= floorSeconds(a.now()) {
|
||||
return false
|
||||
}
|
||||
// Sign the received text, not a re-rendered payload.
|
||||
return secretMatches(segments[2], a.sign(segments[0]+"."+segments[1]))
|
||||
}
|
||||
|
||||
// createCookie mirrors createCookie and returns the Set-Cookie header value,
|
||||
// or "" where TS returned undefined (site access is not required).
|
||||
func (a *siteAccessGate) createCookie() string {
|
||||
if !a.required() {
|
||||
return ""
|
||||
}
|
||||
expiresAt := floorSeconds(a.now()) + int64(a.ttlSeconds)
|
||||
payload := cookieVersion + "." + strconv.FormatInt(expiresAt, 10)
|
||||
value := payload + "." + a.sign(payload)
|
||||
// Attribute order is the TS array order; the app writes it verbatim.
|
||||
attributes := []string{
|
||||
a.cookieName() + "=" + value,
|
||||
"Path=/",
|
||||
"Max-Age=" + strconv.Itoa(a.ttlSeconds),
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
}
|
||||
if a.secure {
|
||||
attributes = append(attributes, "Secure")
|
||||
}
|
||||
return strings.Join(attributes, "; ")
|
||||
}
|
||||
|
||||
func (a *siteAccessGate) cookieName() string {
|
||||
if a.secure {
|
||||
return "__Host-screener-site-access"
|
||||
}
|
||||
return "screener-site-access"
|
||||
}
|
||||
|
||||
func (a *siteAccessGate) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, []byte(a.password))
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// floorSeconds is Math.floor(milliseconds / 1000); Go's integer division
|
||||
// truncates toward zero, so negative clocks need the adjustment.
|
||||
func floorSeconds(milliseconds int64) int64 {
|
||||
seconds := milliseconds / 1_000
|
||||
if milliseconds < 0 && milliseconds%1_000 != 0 {
|
||||
seconds--
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
// readCookie mirrors the TS reader: the first part whose trimmed name matches
|
||||
// wins, and an empty value counts as absent.
|
||||
func readCookie(header string, name string) string {
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
separator := strings.IndexByte(part, '=')
|
||||
if separator != -1 && strings.TrimSpace(part[:separator]) == name {
|
||||
return strings.TrimSpace(part[separator+1:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// secretMatches compares through SHA-256 digests so the comparison is constant
|
||||
// time regardless of length, exactly as the TS helper does.
|
||||
func secretMatches(actual string, expected string) bool {
|
||||
actualDigest := sha256.Sum256([]byte(actual))
|
||||
expectedDigest := sha256.Sum256([]byte(expected))
|
||||
return subtle.ConstantTimeCompare(actualDigest[:], expectedDigest[:]) == 1
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testAccessPassword = "instance-access-password"
|
||||
|
||||
// newTestAccess builds a siteAccessGate over a mutable clock, mirroring the
|
||||
// `now: () => now` injection used by tests/server-http.test.ts.
|
||||
func newTestAccess(t *testing.T, options siteAccessOptions) *siteAccessGate {
|
||||
t.Helper()
|
||||
access, err := newSiteAccess(options)
|
||||
if err != nil {
|
||||
t.Fatalf("newSiteAccess: %v", err)
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
// cookiePair is the test helper of the same name: the `name=value` pair of a
|
||||
// Set-Cookie header, which is what a browser sends back.
|
||||
func cookiePair(t *testing.T, setCookie string) string {
|
||||
t.Helper()
|
||||
if setCookie == "" {
|
||||
t.Fatal("expected a Set-Cookie value")
|
||||
}
|
||||
return strings.SplitN(setCookie, ";", 2)[0]
|
||||
}
|
||||
|
||||
// Ports "reports status and issues a stateless 24-hour cookie".
|
||||
func TestSiteAccessIssuesStatelessDayLongCookie(t *testing.T) {
|
||||
now := int64(1_000)
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return now },
|
||||
})
|
||||
|
||||
if !access.required() {
|
||||
t.Fatal("a configured password must make site access required")
|
||||
}
|
||||
if access.isAuthenticated("") {
|
||||
t.Fatal("a request without a cookie must not be authenticated")
|
||||
}
|
||||
if access.passwordMatches("wrong-password") {
|
||||
t.Fatal("a wrong password must be rejected")
|
||||
}
|
||||
if !access.passwordMatches(testAccessPassword) {
|
||||
t.Fatal("the configured password must be accepted")
|
||||
}
|
||||
|
||||
setCookie := access.createCookie()
|
||||
if want := "screener-site-access=v1."; !strings.Contains(setCookie, want) {
|
||||
t.Fatalf("Set-Cookie %q does not contain %q", setCookie, want)
|
||||
}
|
||||
for _, attribute := range []string{"Path=/", "Max-Age=86400", "HttpOnly", "SameSite=Strict"} {
|
||||
if !strings.Contains(setCookie, attribute) {
|
||||
t.Fatalf("Set-Cookie %q is missing %q", setCookie, attribute)
|
||||
}
|
||||
}
|
||||
if strings.Contains(setCookie, "Secure") {
|
||||
t.Fatalf("Set-Cookie %q must not be Secure without HTTPS", setCookie)
|
||||
}
|
||||
if strings.Contains(setCookie, testAccessPassword) {
|
||||
t.Fatal("Set-Cookie must never carry the access password")
|
||||
}
|
||||
|
||||
if !access.isAuthenticated(cookiePair(t, setCookie)) {
|
||||
t.Fatal("the freshly issued cookie must authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
// Ports the exact attribute order of createCookie, which app.ts writes verbatim
|
||||
// into the Set-Cookie header.
|
||||
func TestSiteAccessCookieAttributeOrder(t *testing.T) {
|
||||
insecure := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return 1_000 },
|
||||
TTLSeconds: 12,
|
||||
})
|
||||
secure := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Secure: true,
|
||||
Now: func() int64 { return 1_000 },
|
||||
TTLSeconds: 12,
|
||||
})
|
||||
|
||||
signature := strings.Split(cookiePair(t, insecure.createCookie()), ".")[2]
|
||||
if len(signature) != 43 {
|
||||
t.Fatalf("signature %q is %d characters, want 43 (base64url, no padding)", signature, len(signature))
|
||||
}
|
||||
|
||||
// now = 1000 ms -> floor(1) + 12 = 13 seconds.
|
||||
wantValue := "v1.13." + signature
|
||||
want := "screener-site-access=" + wantValue +
|
||||
"; Path=/; Max-Age=12; HttpOnly; SameSite=Strict"
|
||||
if got := insecure.createCookie(); got != want {
|
||||
t.Fatalf("CreateCookie() = %q, want %q", got, want)
|
||||
}
|
||||
wantSecure := "__Host-screener-site-access=" + wantValue +
|
||||
"; Path=/; Max-Age=12; HttpOnly; SameSite=Strict; Secure"
|
||||
if got := secure.createCookie(); got != wantSecure {
|
||||
t.Fatalf("secure CreateCookie() = %q, want %q", got, wantSecure)
|
||||
}
|
||||
}
|
||||
|
||||
// Ports "uses a secure __Host- cookie for production HTTPS".
|
||||
func TestSiteAccessUsesHostPrefixedCookieWhenSecure(t *testing.T) {
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Secure: true,
|
||||
Now: func() int64 { return 1_000 },
|
||||
})
|
||||
setCookie := access.createCookie()
|
||||
|
||||
for _, attribute := range []string{
|
||||
"__Host-screener-site-access=", "Secure", "HttpOnly", "SameSite=Strict", "Path=/",
|
||||
} {
|
||||
if !strings.Contains(setCookie, attribute) {
|
||||
t.Fatalf("Set-Cookie %q is missing %q", setCookie, attribute)
|
||||
}
|
||||
}
|
||||
if strings.Contains(setCookie, "Domain=") {
|
||||
t.Fatalf("Set-Cookie %q must not carry a Domain for a __Host- cookie", setCookie)
|
||||
}
|
||||
if !access.isAuthenticated(cookiePair(t, setCookie)) {
|
||||
t.Fatal("the secure cookie must authenticate under its own name")
|
||||
}
|
||||
// The cookie name is part of the signed identity only through the lookup;
|
||||
// an insecure instance never finds a __Host- cookie.
|
||||
insecure := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return 1_000 },
|
||||
})
|
||||
if insecure.isAuthenticated(cookiePair(t, setCookie)) {
|
||||
t.Fatal("the insecure instance must not read the __Host- cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// Ports "renews an authenticated cookie across successive idle deadlines".
|
||||
func TestSiteAccessRenewsAcrossSuccessiveIdleDeadlines(t *testing.T) {
|
||||
now := int64(1_000)
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return now },
|
||||
TTLSeconds: 12,
|
||||
})
|
||||
|
||||
originalCookie := cookiePair(t, access.createCookie())
|
||||
|
||||
now = 7_000
|
||||
if !access.isAuthenticated(originalCookie) {
|
||||
t.Fatal("the original cookie must still be valid at 7s")
|
||||
}
|
||||
firstRenewedCookie := cookiePair(t, access.createCookie())
|
||||
if firstRenewedCookie == originalCookie {
|
||||
t.Fatal("a renewal at a later second must produce a different cookie")
|
||||
}
|
||||
|
||||
now = 13_000
|
||||
if access.isAuthenticated(originalCookie) {
|
||||
t.Fatal("the original cookie must expire 12s after it was issued")
|
||||
}
|
||||
if !access.isAuthenticated(firstRenewedCookie) {
|
||||
t.Fatal("the first renewal must still be valid at 13s")
|
||||
}
|
||||
secondRenewedCookie := cookiePair(t, access.createCookie())
|
||||
if secondRenewedCookie == firstRenewedCookie {
|
||||
t.Fatal("the second renewal must differ from the first")
|
||||
}
|
||||
|
||||
now = 20_000
|
||||
if access.isAuthenticated(firstRenewedCookie) {
|
||||
t.Fatal("the first renewal must expire 12s after it was issued")
|
||||
}
|
||||
if !access.isAuthenticated(secondRenewedCookie) {
|
||||
t.Fatal("the second renewal must still be valid at 20s")
|
||||
}
|
||||
}
|
||||
|
||||
// Ports "rejects an expired or modified cookie" plus the parsing rules the HTTP
|
||||
// scenarios only reach indirectly.
|
||||
func TestSiteAccessRejectsExpiredOrTamperedCookies(t *testing.T) {
|
||||
now := int64(1_000)
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return now },
|
||||
TTLSeconds: 1,
|
||||
})
|
||||
cookie := cookiePair(t, access.createCookie())
|
||||
value := strings.SplitN(cookie, "=", 2)[1]
|
||||
segments := strings.Split(value, ".")
|
||||
|
||||
if access.isAuthenticated(cookie + "x") {
|
||||
t.Fatal("an appended character must invalidate the signature")
|
||||
}
|
||||
|
||||
other := newTestAccess(t, siteAccessOptions{
|
||||
Password: "another-access-password",
|
||||
Now: func() int64 { return now },
|
||||
TTLSeconds: 1,
|
||||
})
|
||||
if other.isAuthenticated(cookie) {
|
||||
t.Fatal("a cookie signed with a different password must be rejected")
|
||||
}
|
||||
|
||||
// Expiry is compared against floor(now / 1000): 2000 ms is second 2 and the
|
||||
// cookie expires at second 2, so `expiresAt <= now` rejects it.
|
||||
now = 2_000
|
||||
if access.isAuthenticated(cookie) {
|
||||
t.Fatal("an expired cookie must be rejected")
|
||||
}
|
||||
now = 1_000
|
||||
|
||||
name := strings.SplitN(cookie, "=", 2)[0]
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
cookie string
|
||||
}{
|
||||
{"no cookie header", ""},
|
||||
{"different cookie name", "other=" + value},
|
||||
{"empty value", name + "="},
|
||||
{"no equals sign", name},
|
||||
{"wrong version", name + "=v2." + segments[1] + "." + segments[2]},
|
||||
{"missing version", name + "=" + segments[1] + "." + segments[2]},
|
||||
{"extra segment", name + "=" + value + ".extra"},
|
||||
{"non-digit expiry", name + "=v1.1e3." + segments[2]},
|
||||
{"signed expiry", name + "=v1.+42." + segments[2]},
|
||||
{"empty expiry", name + "=v1.." + segments[2]},
|
||||
{"short signature", name + "=v1." + segments[1] + "." + segments[2][:42]},
|
||||
{"long signature", name + "=v1." + segments[1] + "." + segments[2] + "A"},
|
||||
{"padded signature", name + "=v1." + segments[1] + "." + segments[2][:42] + "="},
|
||||
{"unsafe expiry", name + "=v1.9007199254740992." + segments[2]},
|
||||
} {
|
||||
if access.isAuthenticated(testCase.cookie) {
|
||||
t.Errorf("%s: %q must not authenticate", testCase.name, testCase.cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// Surrounding cookies and whitespace are tolerated, as the TS reader is.
|
||||
for _, header := range []string{
|
||||
"a=1; " + cookie + "; b=2",
|
||||
"a=1;" + cookie,
|
||||
" " + name + " = " + value + " ",
|
||||
} {
|
||||
if !access.isAuthenticated(header) {
|
||||
t.Errorf("Cookie header %q must authenticate", header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ports "is immediately authenticated when the access password is empty".
|
||||
func TestSiteAccessWithoutPassword(t *testing.T) {
|
||||
access := newTestAccess(t, siteAccessOptions{Now: func() int64 { return 1_000 }})
|
||||
|
||||
if access.required() {
|
||||
t.Fatal("an empty password must not require site access")
|
||||
}
|
||||
if !access.isAuthenticated("") {
|
||||
t.Fatal("every request must be authenticated without a password")
|
||||
}
|
||||
if !access.isAuthenticated("screener-site-access=nonsense") {
|
||||
t.Fatal("a bogus cookie must not matter without a password")
|
||||
}
|
||||
if !access.passwordMatches("") || !access.passwordMatches("anything") {
|
||||
t.Fatal("passwordMatches must be true without a password")
|
||||
}
|
||||
if got := access.createCookie(); got != "" {
|
||||
t.Fatalf("CreateCookie() = %q, want \"\" (TS returned undefined)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSiteAccessTTLValidation(t *testing.T) {
|
||||
if _, err := newSiteAccess(siteAccessOptions{Password: testAccessPassword, TTLSeconds: -1}); err == nil {
|
||||
t.Fatal("a negative TTL must be rejected")
|
||||
}
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Now: func() int64 { return 0 },
|
||||
})
|
||||
if !strings.Contains(access.createCookie(), "Max-Age=86400") {
|
||||
t.Fatal("an unset TTL must default to 24 hours")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Package app ports src/server/app.ts: the HTTP composition, the startup and
|
||||
// shutdown lifecycle, and the request router. access.go ports
|
||||
// src/server/access-session.ts and static.go the sirv frontend handler.
|
||||
//
|
||||
// # Locking
|
||||
//
|
||||
// signal.Server owns the one mutex that guards the room store, the media router
|
||||
// and every session. This package never takes it: everything a request can
|
||||
// mutate goes through the signaling server. Server.mu below guards only this
|
||||
// struct's own lifecycle bookkeeping.
|
||||
//
|
||||
// The lock order is one-directional: Server.mu may be held while the signaling
|
||||
// mutex is taken (reconcile calls signal.New under it, and signal.New locks the
|
||||
// server it is still building), never the reverse. Nothing reachable under the
|
||||
// signaling mutex may take Server.mu, which is why the one callback this
|
||||
// package hands to signal, SiteAccessAtUpgrade, only reads the lock-free
|
||||
// site-access gate. Keep it that way or the two locks can deadlock.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/config"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/room"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/signal"
|
||||
)
|
||||
|
||||
// Node HTTP server settings of app.ts, mapped in map section 3.3 / D11.
|
||||
const (
|
||||
requestTimeout = 10 * time.Second // httpServer.requestTimeout
|
||||
headersTimeout = 15 * time.Second // httpServer.headersTimeout
|
||||
keepAliveTimeout = 5 * time.Second // httpServer.keepAliveTimeout
|
||||
)
|
||||
|
||||
// Options is CreateServerOptions. Every zero value means the TS `undefined`.
|
||||
type Options struct {
|
||||
// Config is the loaded server configuration; cmd owns config.Load / Local.
|
||||
Config config.Config
|
||||
// Assets is the built Browser UI. A nil FS serves the API only, which is
|
||||
// the TS frontend mode "none"; webassets.FS() returns nil the same way
|
||||
// when no build was embedded (D3).
|
||||
Assets fs.FS
|
||||
// Now returns Unix milliseconds; nil uses the wall clock.
|
||||
Now func() int64
|
||||
// Logger receives the request-failure record; nil uses slog.Default().
|
||||
Logger *slog.Logger
|
||||
|
||||
AuthenticationTimeoutMs int
|
||||
ViewerDisconnectGraceMs int
|
||||
HeartbeatIntervalMs int
|
||||
CleanupIntervalMs int
|
||||
MaxSignalConnections int
|
||||
MaxUnauthenticatedSignalConnections int
|
||||
SiteAccessTTLSeconds int
|
||||
|
||||
// SFUTokenIssuer and SFURoomControl replace the LiveKit implementations
|
||||
// this package would otherwise construct from Config.LiveKit.
|
||||
SFUTokenIssuer sfu.TokenIssuer
|
||||
SFURoomControl sfu.RoomControl
|
||||
// AfterFunc is the signaling timer factory (D5); nil uses time.AfterFunc.
|
||||
AfterFunc func(time.Duration, func()) func() bool
|
||||
// RoomStore replaces the store this package would build from Config.
|
||||
RoomStore *room.Store
|
||||
}
|
||||
|
||||
// Server is ScreenerServer.
|
||||
type Server struct {
|
||||
config config.Config
|
||||
store *room.Store
|
||||
siteAccess *siteAccessGate
|
||||
frontend http.Handler
|
||||
logger *slog.Logger
|
||||
// roomControl is nil without a LiveKit fallback; it is the only startup
|
||||
// reconciliation step (A12).
|
||||
roomControl sfu.RoomControl
|
||||
signalOptions signal.Options
|
||||
httpServer *http.Server
|
||||
// listener is nil until Listen binds. It is written before startupDone is
|
||||
// closed and read only after that channel settles, so it needs no lock.
|
||||
listener net.Listener
|
||||
|
||||
// acceptingTraffic and signaling are the two closure variables app.ts
|
||||
// flipped together; both are read by request goroutines without a lock and
|
||||
// written under mu (hazard 13).
|
||||
acceptingTraffic atomic.Bool
|
||||
signaling atomic.Pointer[signal.Server]
|
||||
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
startupRequested bool
|
||||
// startupDone is closed when listen settles, which is what shutdown awaits
|
||||
// so the listener stays owned until an in-flight startup finishes (A6).
|
||||
startupDone chan struct{}
|
||||
|
||||
shutdownOnce sync.Once
|
||||
shutdownErr error
|
||||
}
|
||||
|
||||
// New is createScreenerServer minus the listening: it builds the SFU fallback,
|
||||
// the room store, the site-access issuer, the frontend handler and the HTTP
|
||||
// server, none of which performs I/O.
|
||||
func New(options Options) (*Server, error) {
|
||||
configuration := options.Config
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = func() int64 { return time.Now().UnixMilli() }
|
||||
}
|
||||
logger := options.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
roomControl, fallback, err := newSfuFallback(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := newRoomStore(options, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siteAccess, err := newSiteAccess(siteAccessOptions{
|
||||
Password: configuration.SiteAccessPassword,
|
||||
Secure: configuration.Env == config.EnvironmentProduction &&
|
||||
configuration.PublicBaseURL.Scheme == "https",
|
||||
Now: now,
|
||||
TTLSeconds: options.SiteAccessTTLSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
config: configuration,
|
||||
store: store,
|
||||
siteAccess: siteAccess,
|
||||
logger: logger,
|
||||
roomControl: roomControl,
|
||||
startupDone: make(chan struct{}),
|
||||
}
|
||||
if options.Assets != nil {
|
||||
server.frontend = staticHandler(options.Assets, http.HandlerFunc(notFoundJSON))
|
||||
}
|
||||
server.signalOptions = signal.Options{
|
||||
Store: store,
|
||||
EndpointMediaCopyCapacity: configuration.EndpointMediaCopyCapacity,
|
||||
SfuFallback: fallback,
|
||||
Ice: config.IceConfig(configuration),
|
||||
// The TS signaling server read natPredictionEnabled off the same
|
||||
// `ice` options object it forwarded; the Go IceConfig is already the
|
||||
// wire shape, so the capability travels beside it.
|
||||
NATPredictionEnabled: configuration.NATPredictionEnabled,
|
||||
AllowedOrigins: configuration.AllowedOrigins,
|
||||
SiteAccessAtUpgrade: func(request *http.Request) bool {
|
||||
return siteAccess.isAuthenticated(cookieHeader(request))
|
||||
},
|
||||
PublicBaseURL: configuration.PublicBaseURL,
|
||||
Now: now,
|
||||
AuthenticationTimeoutMs: options.AuthenticationTimeoutMs,
|
||||
ViewerDisconnectGraceMs: options.ViewerDisconnectGraceMs,
|
||||
HeartbeatIntervalMs: options.HeartbeatIntervalMs,
|
||||
CleanupIntervalMs: options.CleanupIntervalMs,
|
||||
MaxConnections: options.MaxSignalConnections,
|
||||
MaxUnauthenticatedConnections: options.MaxUnauthenticatedSignalConnections,
|
||||
AfterFunc: options.AfterFunc,
|
||||
Logger: logger,
|
||||
}
|
||||
server.httpServer = &http.Server{
|
||||
Handler: server,
|
||||
ReadHeaderTimeout: headersTimeout,
|
||||
ReadTimeout: requestTimeout,
|
||||
IdleTimeout: keepAliveTimeout,
|
||||
// No WriteTimeout: Node had none, and it would cut long downloads.
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// newSfuFallback builds the LiveKit half of createScreenerServer. Without a
|
||||
// LiveKit fallback in the configuration both results are nil and the signaling
|
||||
// server runs peer-only. The returned RoomControl is also kept by the caller,
|
||||
// because it is the one startup reconciliation step (A12).
|
||||
func newSfuFallback(options Options) (sfu.RoomControl, *signal.SfuFallback, error) {
|
||||
livekit := options.Config.LiveKit
|
||||
if livekit == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
roomControl := options.SFURoomControl
|
||||
if roomControl == nil {
|
||||
control, err := sfu.NewLiveKitRoomControl(sfu.LiveKitRoomControlOptions{
|
||||
APIURL: livekit.APIURL,
|
||||
APIKey: livekit.APIKey,
|
||||
APISecret: livekit.APISecret,
|
||||
MaxViewersPerRoom: options.Config.MaxViewersPerRoom,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
roomControl = control
|
||||
}
|
||||
tokenIssuer := options.SFUTokenIssuer
|
||||
if tokenIssuer == nil {
|
||||
issuer, err := sfu.NewLiveKitTokenIssuer(sfu.LiveKitTokenIssuerOptions{
|
||||
APIKey: livekit.APIKey,
|
||||
APISecret: livekit.APISecret,
|
||||
MaxViewersPerRoom: options.Config.MaxViewersPerRoom,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tokenIssuer = issuer
|
||||
}
|
||||
return roomControl, &signal.SfuFallback{
|
||||
URL: livekit.URL,
|
||||
TokenIssuer: tokenIssuer,
|
||||
Admission: sfu.NewAdmission(sfu.AdmissionOptions{
|
||||
IngressCapacity: room.Capacity,
|
||||
EgressCapacity: room.Capacity * options.Config.MaxViewersPerRoom,
|
||||
}),
|
||||
RoomControl: roomControl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newRoomStore builds the room store createScreenerServer would otherwise take
|
||||
// from Options.RoomStore. An empty RoomDatabasePath is the memory-only mode.
|
||||
func newRoomStore(options Options, now func() int64) (*room.Store, error) {
|
||||
if options.RoomStore != nil {
|
||||
return options.RoomStore, nil
|
||||
}
|
||||
var database *room.Database
|
||||
if options.Config.RoomDatabasePath != "" {
|
||||
// The file is opened by store.Initialize, not here: a failed bind must
|
||||
// leave no database behind.
|
||||
opened, err := room.NewDatabase(options.Config.RoomDatabasePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
database = opened
|
||||
}
|
||||
return room.New(room.Options{
|
||||
LeaseMs: options.Config.RoomLeaseMs,
|
||||
MaxRooms: room.Capacity,
|
||||
MaxViewersPerRoom: options.Config.MaxViewersPerRoom,
|
||||
Database: database,
|
||||
Now: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Handler is the composed router, exposed so a host process can mount it.
|
||||
func (s *Server) Handler() http.Handler { return s }
|
||||
|
||||
// Store is ScreenerServer.roomStore. The signaling server's mutex guards it, so
|
||||
// callers outside that lock may only read it while no request is in flight.
|
||||
func (s *Server) Store() *room.Store { return s.store }
|
||||
|
||||
// Listen binds the configured address, then reconciles the stable authority and
|
||||
// LiveKit, and only then starts accepting traffic. It returns the bound port,
|
||||
// which is the actual one when Config.Port is 0.
|
||||
func (s *Server) Listen(ctx context.Context) (int, error) {
|
||||
s.mu.Lock()
|
||||
if s.startupRequested {
|
||||
s.mu.Unlock()
|
||||
return 0, errors.New("Screener server startup was already requested")
|
||||
}
|
||||
if s.closing {
|
||||
s.mu.Unlock()
|
||||
return 0, errors.New("Screener server is closing")
|
||||
}
|
||||
s.startupRequested = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// Deferred: a rejected startupOperation still settled, so shutdown's wait
|
||||
// on it ended (app.ts:219). A panic escaping start() must likewise release
|
||||
// the latch, or every later Close blocks on it for good.
|
||||
defer close(s.startupDone)
|
||||
return s.start(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) start(ctx context.Context) (int, error) {
|
||||
// A11: bind first. A listener owned by another process must fail before
|
||||
// the room database is opened and before LiveKit is called at all.
|
||||
address := net.JoinHostPort(s.config.ListenHost, strconv.Itoa(s.config.Port))
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.listener = listener
|
||||
go func() {
|
||||
// bindHttpServer removed its "error" listener once the server was
|
||||
// listening (app.ts:315-333), so a later listener failure ended the
|
||||
// Node process. Serve only returns something other than
|
||||
// ErrServerClosed on a permanent accept failure, which nothing here
|
||||
// causes; report it rather than serving nothing in silence.
|
||||
if err := s.httpServer.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
s.logger.Error("Screener HTTP server stopped unexpectedly", "error", err)
|
||||
}
|
||||
}()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if err := s.reconcile(ctx); err != nil {
|
||||
// A13: give the database and the listener back before reporting.
|
||||
var cleanup []error
|
||||
if closeErr := s.store.Close(); closeErr != nil {
|
||||
cleanup = append(cleanup, closeErr)
|
||||
}
|
||||
if closeErr := s.stopServing(ctx); closeErr != nil {
|
||||
cleanup = append(cleanup, closeErr)
|
||||
}
|
||||
if len(cleanup) > 0 {
|
||||
return 0, fmt.Errorf("Screener startup reconciliation failed: %w",
|
||||
errors.Join(append([]error{err}, cleanup...)...))
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// stopServing is closeHttpServer. Shutdown stops accepting and drains, but it
|
||||
// can lose the race against the Serve goroutine registering the listener, in
|
||||
// which case it returns without having closed the bound socket; closing the
|
||||
// listener afterwards makes the port release synchronous. A second close of an
|
||||
// already closed listener is a no-op error nobody reported in TS either, so
|
||||
// only Shutdown's error is returned.
|
||||
func (s *Server) stopServing(ctx context.Context) error {
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// reconcile is the body of the TS startup try block: the store recovers its
|
||||
// stable authority synchronously, LiveKit reconciliation is the one await
|
||||
// (A12), and traffic starts only if no shutdown began while it ran.
|
||||
func (s *Server) reconcile(ctx context.Context) error {
|
||||
if err := s.store.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.roomControl != nil {
|
||||
if err := s.roomControl.Initialize(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Holding mu across the check and the two stores is what made the TS
|
||||
// single thread safe here: a shutdown that already set closing must never
|
||||
// see traffic re-enabled behind it.
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closing {
|
||||
return nil
|
||||
}
|
||||
signaling, err := signal.New(s.signalOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.signaling.Store(signaling)
|
||||
s.acceptingTraffic.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops accepting traffic, closes the signaling server, the listener and
|
||||
// the room store, and leaves the rooms in place. Hosted restarts use it.
|
||||
func (s *Server) Close(ctx context.Context) error {
|
||||
return s.shutdown(ctx, false)
|
||||
}
|
||||
|
||||
// End is Close preceded by ending every room. The Client uses it, because its
|
||||
// authority dies with the process.
|
||||
func (s *Server) End(ctx context.Context) error {
|
||||
return s.shutdown(ctx, true)
|
||||
}
|
||||
|
||||
// shutdown is memoised exactly as the TS shutdownOperation was: the second
|
||||
// caller joins the first and gets its result, whichever entry point it used.
|
||||
func (s *Server) shutdown(ctx context.Context, endRooms bool) error {
|
||||
s.shutdownOnce.Do(func() { s.shutdownErr = s.runShutdown(ctx, endRooms) })
|
||||
return s.shutdownErr
|
||||
}
|
||||
|
||||
func (s *Server) runShutdown(ctx context.Context, endRooms bool) error {
|
||||
// O35: stop traffic, mark closing, then wait for an in-flight startup.
|
||||
s.mu.Lock()
|
||||
s.acceptingTraffic.Store(false)
|
||||
s.closing = true
|
||||
awaitStartup := s.startupRequested
|
||||
s.mu.Unlock()
|
||||
if awaitStartup {
|
||||
// A6: the listen caller owns the startup error; shutdown only waits.
|
||||
<-s.startupDone
|
||||
}
|
||||
|
||||
var failures []error
|
||||
signaling := s.signaling.Load()
|
||||
if endRooms {
|
||||
failures = append(failures, s.endAllRooms(signaling))
|
||||
}
|
||||
if signaling != nil {
|
||||
failures = append(failures, signaling.Close(ctx))
|
||||
}
|
||||
// The signaling server must be closed before the listener: its connections
|
||||
// live on it.
|
||||
failures = append(failures, s.stopServing(ctx))
|
||||
failures = append(failures, s.store.Close())
|
||||
if joined := errors.Join(failures...); joined != nil {
|
||||
return fmt.Errorf("Screener server shutdown failed: %w", joined)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endAllRooms is the TS `signaling ? signaling.endAllRooms() :
|
||||
// roomStore.abandonAllRooms()`: without a signaling server nobody is connected,
|
||||
// so the store can be closed down directly.
|
||||
func (s *Server) endAllRooms(signaling *signal.Server) error {
|
||||
if signaling != nil {
|
||||
return signaling.EndAllRooms()
|
||||
}
|
||||
_, err := s.store.AbandonAllRooms()
|
||||
return err
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Request and response helpers ported from src/server/app.ts.
|
||||
|
||||
// maxJSONRequestBytes is the readJsonBody(request, 1_024) budget every JSON
|
||||
// route shares.
|
||||
const maxJSONRequestBytes = 1024
|
||||
|
||||
// errorBody is the { error } shape every failing route answers with.
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// healthBody is the /healthz payload.
|
||||
type healthBody struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// siteAccessBody is siteAccessStatus(); the key order is the TS literal's.
|
||||
type siteAccessBody struct {
|
||||
Required bool `json:"required"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
// sendJSON always sets the exact Content-Length and the charset, as app.ts did.
|
||||
// Encoding one of this package's own values cannot fail, so a marshal error is
|
||||
// a programming error and reaches the recover wrapper.
|
||||
func sendJSON(writer http.ResponseWriter, status int, body any) {
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
// D9: JSON.stringify does not escape HTML either.
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(body); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
encoded := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
header := writer.Header()
|
||||
header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
header.Set("Content-Length", strconv.Itoa(len(encoded)))
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write(encoded)
|
||||
}
|
||||
|
||||
// readJSONBody ports readJsonBody: the content type must be exactly
|
||||
// application/json once its parameters are cut, the body must be at most 1024
|
||||
// bytes and must not be empty. Every failure collapses to the route's 400, so
|
||||
// the messages only exist to keep the TS reasons readable.
|
||||
func readJSONBody(request *http.Request) ([]byte, error) {
|
||||
contentType, _, _ := strings.Cut(request.Header.Get("Content-Type"), ";")
|
||||
if strings.TrimSpace(contentType) != "application/json" {
|
||||
return nil, errors.New("Request content type must be application/json")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(request.Body, maxJSONRequestBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxJSONRequestBytes {
|
||||
return nil, errors.New("Request body is too large")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, errors.New("Request body is required")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// hasRequestBody ports hasRequestBody. net/http parses Content-Length and moves
|
||||
// Transfer-Encoding out of Header, so a chunked body is ContentLength -1 and an
|
||||
// unparseable length never reaches a handler at all.
|
||||
func hasRequestBody(request *http.Request) bool {
|
||||
return len(request.TransferEncoding) > 0 || request.ContentLength != 0
|
||||
}
|
||||
|
||||
// requestPath is the WHATWG `url.pathname` app.ts routed and logged on. It is
|
||||
// the escaped path on purpose: `new URL()` does not percent-decode, so
|
||||
// "/api%2frooms" must not reach the /api/rooms route.
|
||||
//
|
||||
// Deviation: `new URL()` also resolves "." and ".." segments, which net/http
|
||||
// leaves in the path. "/x/../api/rooms" therefore reaches the frontend handler
|
||||
// here where TypeScript routed it to room creation. Only stricter: a raw path
|
||||
// that already names a route still matches, so nothing can slip past an origin,
|
||||
// cookie or token check that way.
|
||||
func requestPath(request *http.Request) string {
|
||||
return request.URL.EscapedPath()
|
||||
}
|
||||
|
||||
// cookieHeader rebuilds the single Cookie header value the Node parser handed
|
||||
// to access-session.ts; Go keeps repeated Cookie headers apart.
|
||||
func cookieHeader(request *http.Request) string {
|
||||
return strings.Join(request.Header.Values("Cookie"), "; ")
|
||||
}
|
||||
|
||||
// bearerToken ports readBearerToken: the prefix is case sensitive and an empty
|
||||
// token counts as absent.
|
||||
func bearerToken(request *http.Request) string {
|
||||
authorization := request.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authorization, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(authorization, "Bearer ")
|
||||
}
|
||||
|
||||
// isUpgradeRequest reproduces the condition under which Node emitted "upgrade"
|
||||
// instead of "request": the parser needs both a Connection: upgrade token and
|
||||
// an Upgrade header. The SignalingServer, not this router, owns the rejection
|
||||
// ladder for a path or query that is not exactly /signal.
|
||||
func isUpgradeRequest(request *http.Request) bool {
|
||||
if request.Header.Get("Upgrade") == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(request.Header.Get("Connection"), ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(token), "upgrade") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// responseRecorder tracks response.headersSent so the recover wrapper can pick
|
||||
// between the JSON 500 and destroying the connection, as app.ts did.
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (recorder *responseRecorder) WriteHeader(status int) {
|
||||
recorder.wrote = true
|
||||
recorder.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (recorder *responseRecorder) Write(data []byte) (int, error) {
|
||||
recorder.wrote = true
|
||||
return recorder.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the real writer's Flush and Hijack.
|
||||
func (recorder *responseRecorder) Unwrap() http.ResponseWriter {
|
||||
return recorder.ResponseWriter
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/config"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/room"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/signal"
|
||||
)
|
||||
|
||||
// The request router of src/server/app.ts. Route order is a contract (map
|
||||
// section 4.3, O34): the first match wins and the /api/ catch-all sits after
|
||||
// every concrete route.
|
||||
|
||||
var (
|
||||
roomReplacementPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/replacement$`)
|
||||
roomAccessPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/access$`)
|
||||
)
|
||||
|
||||
// ServeHTTP is the createServer request listener of app.ts.
|
||||
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
recorder := &responseRecorder{ResponseWriter: writer}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
return
|
||||
}
|
||||
if recovered == http.ErrAbortHandler {
|
||||
panic(recovered)
|
||||
}
|
||||
// TS: console.error("HTTP request failed", {method, path, error}).
|
||||
// D11 keeps the method and the path only.
|
||||
s.requestFailed(recorder, request)
|
||||
}()
|
||||
|
||||
if !s.acceptingTraffic.Load() {
|
||||
// Hazard 13: no traffic at all until the SignalingServer exists, so
|
||||
// /healthz answers 503 while LiveKit reconciliation runs.
|
||||
recorder.Header().Set("Cache-Control", "no-store")
|
||||
recorder.Header().Set("Retry-After", "1")
|
||||
sendJSON(recorder, http.StatusServiceUnavailable, errorBody{"Service starting"})
|
||||
return
|
||||
}
|
||||
signaling := s.signaling.Load()
|
||||
if isUpgradeRequest(request) {
|
||||
// Node routed every upgrade to the SignalingServer's "upgrade"
|
||||
// listener, which was attached only while traffic was accepted.
|
||||
signaling.ServeHTTP(recorder, request)
|
||||
return
|
||||
}
|
||||
s.route(recorder, request, signaling)
|
||||
}
|
||||
|
||||
func (s *Server) route(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
signaling *signal.Server,
|
||||
) {
|
||||
path := requestPath(request)
|
||||
|
||||
if path == "/healthz" {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
if !allowMethod(writer, request, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
sendJSON(writer, http.StatusOK, healthBody{"ok"})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/api/capabilities" {
|
||||
noStoreJSON(writer)
|
||||
if !allowMethod(writer, request, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
|
||||
NatPrediction: s.config.NATPredictionEnabled,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/api/site-access" {
|
||||
s.handleSiteAccess(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
if match := roomReplacementPath.FindStringSubmatch(path); match != nil {
|
||||
s.handleRoomReplacement(writer, request, signaling, match[1])
|
||||
return
|
||||
}
|
||||
|
||||
if match := roomAccessPath.FindStringSubmatch(path); match != nil {
|
||||
s.handleRoomAccess(writer, request, signaling, match[1])
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/api/rooms" {
|
||||
s.handleRoomCreation(writer, request, signaling)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
// The catch-all sets no nosniff header; only the concrete routes do.
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
sendJSON(writer, http.StatusNotFound, errorBody{"Not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if s.frontend != nil {
|
||||
s.frontend.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
notFoundJSON(writer, request)
|
||||
}
|
||||
|
||||
// notFoundJSON is the fallthrough app.ts handed to sirv as its next()
|
||||
// callback and used when no frontend is composed at all.
|
||||
func notFoundJSON(writer http.ResponseWriter, _ *http.Request) {
|
||||
sendJSON(writer, http.StatusNotFound, errorBody{"Not found"})
|
||||
}
|
||||
|
||||
// noStoreJSON sets the two headers every /api route sets before its method
|
||||
// check, so a 405 carries them too.
|
||||
func noStoreJSON(writer http.ResponseWriter) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
}
|
||||
|
||||
// allowMethod answers the 405 row of the HTTP table, Allow header included.
|
||||
func allowMethod(writer http.ResponseWriter, request *http.Request, allowed string) bool {
|
||||
if request.Method == allowed {
|
||||
return true
|
||||
}
|
||||
writer.Header().Set("Allow", allowed)
|
||||
sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"})
|
||||
return false
|
||||
}
|
||||
|
||||
// handleSiteAccess is handleSiteAccessRequest of app.ts.
|
||||
func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Request) {
|
||||
noStoreJSON(writer)
|
||||
|
||||
if request.Method == http.MethodGet {
|
||||
status := siteAccessBody{
|
||||
Required: s.siteAccess.required(),
|
||||
Authenticated: s.siteAccess.isAuthenticated(cookieHeader(request)),
|
||||
}
|
||||
if status.Required && status.Authenticated {
|
||||
// The Set-Cookie value is written verbatim, in the TS attribute
|
||||
// order (D11).
|
||||
writer.Header().Set("Set-Cookie", s.siteAccess.createCookie())
|
||||
}
|
||||
sendJSON(writer, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Method != http.MethodPost {
|
||||
writer.Header().Set("Allow", "GET, POST")
|
||||
sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.allowedRequestOrigin(request) {
|
||||
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
|
||||
return
|
||||
}
|
||||
if hasRequestBody(request) {
|
||||
sendJSON(writer, http.StatusBadRequest, errorBody{"Request body is not accepted"})
|
||||
return
|
||||
}
|
||||
if !s.siteAccess.required() {
|
||||
sendJSON(writer, http.StatusOK, siteAccessBody{Required: false, Authenticated: true})
|
||||
return
|
||||
}
|
||||
if provided := bearerToken(request); provided == "" || !s.siteAccess.passwordMatches(provided) {
|
||||
writer.Header().Set("WWW-Authenticate", "Bearer")
|
||||
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Set-Cookie", s.siteAccess.createCookie())
|
||||
sendJSON(writer, http.StatusOK, siteAccessBody{Required: true, Authenticated: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleRoomReplacement(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
signaling *signal.Server,
|
||||
roomID string,
|
||||
) {
|
||||
noStoreJSON(writer)
|
||||
if !allowMethod(writer, request, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
hostToken, authorized := s.authorizedRoomHostToken(writer, request)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
body, err := readJSONBody(request)
|
||||
var parsed protocol.ReplaceRoomRequest
|
||||
if err == nil {
|
||||
parsed, err = protocol.DecodeReplaceRoomRequest(body)
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room replacement request"})
|
||||
return
|
||||
}
|
||||
if signaling == nil {
|
||||
sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"})
|
||||
return
|
||||
}
|
||||
created, err := signaling.ReplaceRoom(
|
||||
roomID,
|
||||
hostToken,
|
||||
parsed.CodeEntryPolicy,
|
||||
parsed.RoomPassword.Value,
|
||||
)
|
||||
if err != nil {
|
||||
if status, message, mapped := replacementFailure(err); mapped {
|
||||
sendJSON(writer, status, errorBody{message})
|
||||
return
|
||||
}
|
||||
s.requestFailed(writer, request)
|
||||
return
|
||||
}
|
||||
sendJSON(writer, http.StatusCreated, s.createRoomResponse(created))
|
||||
}
|
||||
|
||||
func (s *Server) handleRoomAccess(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
signaling *signal.Server,
|
||||
roomID string,
|
||||
) {
|
||||
noStoreJSON(writer)
|
||||
if !allowMethod(writer, request, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
hostToken, authorized := s.authorizedRoomHostToken(writer, request)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
body, err := readJSONBody(request)
|
||||
var parsed protocol.RoomAccessUpdateRequest
|
||||
if err == nil {
|
||||
parsed, err = protocol.DecodeRoomAccessUpdateRequest(body)
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room access request"})
|
||||
return
|
||||
}
|
||||
if signaling == nil {
|
||||
sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"})
|
||||
return
|
||||
}
|
||||
updated, err := signaling.UpdateRoomAccess(roomID, hostToken, parsed)
|
||||
if err != nil {
|
||||
if status, message, mapped := accessFailure(err); mapped {
|
||||
sendJSON(writer, status, errorBody{message})
|
||||
return
|
||||
}
|
||||
s.requestFailed(writer, request)
|
||||
return
|
||||
}
|
||||
sendJSON(writer, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (s *Server) handleRoomCreation(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
signaling *signal.Server,
|
||||
) {
|
||||
noStoreJSON(writer)
|
||||
if !allowMethod(writer, request, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if !s.allowedRequestOrigin(request) {
|
||||
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
|
||||
return
|
||||
}
|
||||
// Hazard 12: the cookie is the only room-creation credential. A
|
||||
// `Bearer <site password>` header must still be 401.
|
||||
if !s.roomCreationAuthorized(request) {
|
||||
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
|
||||
return
|
||||
}
|
||||
body, err := readJSONBody(request)
|
||||
var parsed protocol.CreateRoomRequest
|
||||
if err == nil {
|
||||
parsed, err = protocol.DecodeCreateRoomRequest(body)
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room request"})
|
||||
return
|
||||
}
|
||||
// TS called roomStore.createRoom directly; the store is now guarded by the
|
||||
// signaling server's global mutex, so creation goes through it. There is
|
||||
// no `signaling == nil` answer here, because app.ts had no getSignaling()
|
||||
// check on this route: reconcile stores the signaling server before it
|
||||
// sets acceptingTraffic and never clears it again, so reaching a handler
|
||||
// at all means the pointer is set (the two sibling room routes keep their
|
||||
// 503 only because the TypeScript had one).
|
||||
created, err := signaling.CreateRoom(
|
||||
parsed.CodeEntryPolicy,
|
||||
parsed.RoomPassword.Value,
|
||||
parsed.PreferredRoomID,
|
||||
)
|
||||
if err != nil {
|
||||
if status, message, mapped := creationFailure(err); mapped {
|
||||
sendJSON(writer, status, errorBody{message})
|
||||
return
|
||||
}
|
||||
s.requestFailed(writer, request)
|
||||
return
|
||||
}
|
||||
sendJSON(writer, http.StatusCreated, s.createRoomResponse(created))
|
||||
}
|
||||
|
||||
// authorizedRoomHostToken of app.ts: origin, then site access, then the bearer
|
||||
// Host token, each with its own terminal response.
|
||||
func (s *Server) authorizedRoomHostToken(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) (string, bool) {
|
||||
if !s.allowedRequestOrigin(request) {
|
||||
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
|
||||
return "", false
|
||||
}
|
||||
if !s.roomCreationAuthorized(request) {
|
||||
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
|
||||
return "", false
|
||||
}
|
||||
hostToken := bearerToken(request)
|
||||
if hostToken == "" {
|
||||
// An absent Host token is indistinguishable from a wrong one.
|
||||
sendJSON(writer, http.StatusNotFound, errorBody{"Room not found"})
|
||||
return "", false
|
||||
}
|
||||
return hostToken, true
|
||||
}
|
||||
|
||||
// allowedRequestOrigin ports isStrictlyAllowedRequestOrigin: the header must be
|
||||
// present, must parse, must be exactly its own serialised origin (so
|
||||
// "http://allowed.test/path" is refused) and must be configured.
|
||||
func (s *Server) allowedRequestOrigin(request *http.Request) bool {
|
||||
origin := request.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || config.Origin(parsed) != origin {
|
||||
return false
|
||||
}
|
||||
_, allowed := s.config.AllowedOrigins[origin]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// roomCreationAuthorized ports isRoomCreationAuthorized.
|
||||
func (s *Server) roomCreationAuthorized(request *http.Request) bool {
|
||||
return !s.siteAccess.required() || s.siteAccess.isAuthenticated(cookieHeader(request))
|
||||
}
|
||||
|
||||
// createRoomResponse of app.ts. signal.InviteURL is the one owner of the invite
|
||||
// URL shape; the rotated-grant notification on the signaling side uses it too.
|
||||
func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoomResponse {
|
||||
return protocol.CreateRoomResponse{
|
||||
RoomID: created.RoomID,
|
||||
HostToken: created.HostToken,
|
||||
InviteURL: signal.InviteURL(s.config.PublicBaseURL, created.RoomID, created.ViewerGrant),
|
||||
CodeEntryPolicy: created.CodeEntryPolicy,
|
||||
ExpiresAt: created.ExpiresAt,
|
||||
// The lease is configured in whole seconds; TS divided by 1000 into a
|
||||
// JS number and the response schema declares an integer.
|
||||
RoomLeaseSeconds: protocol.Int(s.config.RoomLeaseMs / 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
// requestFailed is the createServer .catch: log the method and the path only —
|
||||
// never a body, header, token or grant (D11) — then answer 500 unless the
|
||||
// response already started, where Node destroyed the socket.
|
||||
func (s *Server) requestFailed(writer http.ResponseWriter, request *http.Request) {
|
||||
s.logger.Error("HTTP request failed",
|
||||
"method", request.Method, "path", requestPath(request))
|
||||
if recorder, ok := writer.(*responseRecorder); ok && recorder.wrote {
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
sendJSON(writer, http.StatusInternalServerError, errorBody{"Internal server error"})
|
||||
}
|
||||
|
||||
// roomErrorCode returns the RoomStoreError code, or "" when err is not one.
|
||||
func roomErrorCode(err error) room.ErrorCode {
|
||||
var roomError *room.Error
|
||||
if errors.As(err, &roomError) {
|
||||
return roomError.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// replacementFailure maps replaceRoom errors; an unmapped error rethrows into
|
||||
// the 500 handler, as the TS `throw error` did.
|
||||
func replacementFailure(err error) (int, string, bool) {
|
||||
switch roomErrorCode(err) {
|
||||
case room.CodeRoomLimit:
|
||||
return http.StatusServiceUnavailable, "Room capacity reached", true
|
||||
case room.CodeRoomAccessDenied, room.CodeRoomBusy:
|
||||
return http.StatusServiceUnavailable, "Room replacement unavailable", true
|
||||
case room.CodeInvalidToken, room.CodeRoomNotFound, room.CodeRoomExpired:
|
||||
return http.StatusNotFound, "Room not found", true
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
// accessFailure maps updateRoomAccess errors. Unlike the other two tables,
|
||||
// every remaining RoomStoreError is a 404; only a non-store error rethrows.
|
||||
func accessFailure(err error) (int, string, bool) {
|
||||
switch code := roomErrorCode(err); code {
|
||||
case "":
|
||||
return 0, "", false
|
||||
case room.CodeRoomBusy:
|
||||
return http.StatusServiceUnavailable, "Room access update unavailable", true
|
||||
case room.CodeRoomAccessDenied:
|
||||
return http.StatusConflict, "Room access update rejected", true
|
||||
default:
|
||||
return http.StatusNotFound, "Room not found", true
|
||||
}
|
||||
}
|
||||
|
||||
// creationFailure maps createRoom errors.
|
||||
func creationFailure(err error) (int, string, bool) {
|
||||
switch roomErrorCode(err) {
|
||||
case room.CodeRoomLimit:
|
||||
return http.StatusServiceUnavailable, "Room capacity reached", true
|
||||
case room.CodeRoomBusy:
|
||||
return http.StatusServiceUnavailable, "Room creation unavailable", true
|
||||
case room.CodeInvalidToken:
|
||||
return http.StatusBadRequest, "Invalid room request", true
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// staticContentTypes freezes mrmime's answers for the extensions a Vite build
|
||||
// of this repo can emit. Go's mime.TypeByExtension overlays the Windows
|
||||
// registry or /etc/mime.types, so the served Content-Type would otherwise
|
||||
// depend on the machine; with X-Content-Type-Options: nosniff a wrong type on a
|
||||
// font or a webmanifest is user visible. See evidence/runtime.md section 4.2.
|
||||
var staticContentTypes = map[string]string{
|
||||
".html": "text/html;charset=utf-8", // sirv appends ";charset=utf-8" with no space
|
||||
".htm": "text/html;charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".wasm": "application/wasm",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".bin": "application/octet-stream",
|
||||
".xml": "text/xml",
|
||||
".ico": "", // mrmime has no .ico, so sirv sends an EMPTY Content-Type
|
||||
}
|
||||
|
||||
// staticHandler reproduces `sirv(directory, { single: true, setHeaders })` as
|
||||
// src/server/app.ts used it, over an fs.FS instead of a directory walk.
|
||||
// notFound is the app's JSON 404, which sirv reached through its next()
|
||||
// callback. Verified against sirv@3.0.2 in evidence/runtime.md section 4.4.
|
||||
func staticHandler(assets fs.FS, notFound http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
// sirv never inspects req.method: app.ts routes /healthz and /api/**
|
||||
// first, so everything else reaches the frontend handler as-is.
|
||||
cleaned := path.Clean("/" + request.URL.Path)
|
||||
name := strings.TrimPrefix(cleaned, "/")
|
||||
|
||||
serve := func(candidate string) bool {
|
||||
// path.Clean already dropped any "..", and totalist never indexed a
|
||||
// dot-prefixed segment, so neither can be served.
|
||||
if candidate == "" || hasDotSegment(candidate) {
|
||||
return false
|
||||
}
|
||||
file, err := assets.Open(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
seeker, ok := file.(io.ReadSeeker)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// app.ts setHeaders.
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
// Set Content-Type explicitly, possibly to "", so ServeContent
|
||||
// never sniffs; the empty name keeps it from guessing either.
|
||||
writer.Header()["Content-Type"] = []string{staticContentTypes[strings.ToLower(path.Ext(candidate))]}
|
||||
// embed.FS reports a zero ModTime, so no Last-Modified is emitted
|
||||
// and no conditional request is honoured. sirv sends
|
||||
// Last-Modified but never answers 304 either; do not fabricate one.
|
||||
http.ServeContent(writer, request, "", info.ModTime(), seeker)
|
||||
return true
|
||||
}
|
||||
|
||||
// sirv strips a trailing "/" before the lookup, and its FILES map holds
|
||||
// files only, so "/assets/" can never hit a file.
|
||||
if !strings.HasSuffix(request.URL.Path, "/") && serve(name) {
|
||||
return
|
||||
}
|
||||
// opts.single: fall back to index.html unless the request matches one
|
||||
// of sirv's two default ignores.
|
||||
if !looksLikeFile(cleaned) && !strings.Contains(cleaned, "/.well-known") {
|
||||
if serve("index.html") {
|
||||
return
|
||||
}
|
||||
}
|
||||
notFound.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
// hasDotSegment reports whether any path segment starts with '.', reproducing
|
||||
// totalist's filter `!opts.dotfiles && /(^\.|[\\+|\/+]\.)/.test(name)` together
|
||||
// with the `/\.well-known[\\+\/]/` exemption that runs before it.
|
||||
func hasDotSegment(name string) bool {
|
||||
if strings.Contains(name, ".well-known/") {
|
||||
return false
|
||||
}
|
||||
for _, segment := range strings.Split(name, "/") {
|
||||
if strings.HasPrefix(segment, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// looksLikeFile ports sirv's default "any extn" ignore,
|
||||
// /[/]([A-Za-z\s\d~$._-]+\.\w+){1,}$/. Because the character class excludes
|
||||
// "/" and the match is anchored at the end, only the last path segment can
|
||||
// match: it must contain a dot with a non-empty [A-Za-z0-9_] extension after
|
||||
// it, and everything before that dot must be in the class. The class is
|
||||
// ASCII-only, so "/文件.js" and "/café.css" are NOT ignored and do get the
|
||||
// index.html fallback.
|
||||
func looksLikeFile(pathname string) bool {
|
||||
slash := strings.LastIndexByte(pathname, '/')
|
||||
if slash < 0 {
|
||||
return false
|
||||
}
|
||||
segment := pathname[slash+1:]
|
||||
dot := strings.LastIndexByte(segment, '.')
|
||||
if dot <= 0 || dot == len(segment)-1 {
|
||||
return false
|
||||
}
|
||||
for _, character := range segment[:dot] { // [A-Za-z\s\d~$._-]
|
||||
switch {
|
||||
case character >= 'A' && character <= 'Z',
|
||||
character >= 'a' && character <= 'z',
|
||||
character >= '0' && character <= '9',
|
||||
character == '~', character == '$', character == '.',
|
||||
character == '_', character == '-',
|
||||
// URL paths are percent-decoded before this test, so a non-ASCII
|
||||
// space can genuinely reach it; protocol owns the JavaScript
|
||||
// whitespace class.
|
||||
protocol.IsJSWhitespace(character):
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, character := range segment[dot+1:] { // \w+
|
||||
switch {
|
||||
case character >= 'A' && character <= 'Z',
|
||||
character >= 'a' && character <= 'z',
|
||||
character >= '0' && character <= '9',
|
||||
character == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const staticNotFoundBody = `{"error":"Not found"}`
|
||||
|
||||
// staticNotFound is app.ts's `sendJson(response, 404, { error: "Not found" })`,
|
||||
// the next() callback sirv fell through to.
|
||||
var staticNotFound = http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
io.WriteString(writer, staticNotFoundBody)
|
||||
})
|
||||
|
||||
// staticFixture mirrors the tree evidence/runtime.md served through real
|
||||
// sirv@3.0.2, byte lengths included, so the parity table below is comparable.
|
||||
func staticFixture() fs.FS {
|
||||
filled := func(size int) *fstest.MapFile {
|
||||
return &fstest.MapFile{Data: bytes.Repeat([]byte("x"), size)}
|
||||
}
|
||||
return fstest.MapFS{
|
||||
"index.html": filled(100),
|
||||
"assets/app.js": filled(20),
|
||||
"assets/app.css": filled(15),
|
||||
"third-party-licenses.txt": filled(21),
|
||||
"favicon.svg": filled(41),
|
||||
"fonts/inter.woff2": filled(300),
|
||||
"manifest.webmanifest": filled(19),
|
||||
"data.json": filled(7),
|
||||
"nomime.bin": filled(64),
|
||||
"noext": filled(23),
|
||||
".gitkeep": filled(0),
|
||||
".vite/manifest.json": filled(7),
|
||||
".well-known/x.txt": filled(10),
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandlerMatchesSirv replays the 26-row parity table measured against
|
||||
// sirv@3.0.2 in evidence/runtime.md section 4.4.
|
||||
func TestStaticHandlerMatchesSirv(t *testing.T) {
|
||||
handler := staticHandler(staticFixture(), staticNotFound)
|
||||
|
||||
const html = "text/html;charset=utf-8"
|
||||
for _, testCase := range []struct {
|
||||
method string
|
||||
path string
|
||||
headerName string
|
||||
headerValue string
|
||||
status int
|
||||
contentType string
|
||||
contentLength string
|
||||
contentRange string
|
||||
bodyLength int
|
||||
}{
|
||||
{method: "GET", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/host", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/room/1234", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/assets/app.js", status: 200, contentType: "text/javascript", contentLength: "20", bodyLength: 20},
|
||||
{method: "GET", path: "/assets/app.css", status: 200, contentType: "text/css", contentLength: "15", bodyLength: 15},
|
||||
{method: "GET", path: "/third-party-licenses.txt", status: 200, contentType: "text/plain", contentLength: "21", bodyLength: 21},
|
||||
{method: "GET", path: "/favicon.svg", status: 200, contentType: "image/svg+xml", contentLength: "41", bodyLength: 41},
|
||||
{method: "GET", path: "/fonts/inter.woff2", status: 200, contentType: "font/woff2", contentLength: "300", bodyLength: 300},
|
||||
{method: "GET", path: "/manifest.webmanifest", status: 200, contentType: "application/manifest+json", contentLength: "19", bodyLength: 19},
|
||||
{method: "GET", path: "/data.json", status: 200, contentType: "application/json", contentLength: "7", bodyLength: 7},
|
||||
{method: "GET", path: "/nomime.bin", status: 200, contentType: "application/octet-stream", contentLength: "64", bodyLength: 64},
|
||||
// mrmime knows no extension here, so sirv sends an empty Content-Type.
|
||||
{method: "GET", path: "/noext", status: 200, contentType: "", contentLength: "23", bodyLength: 23},
|
||||
{method: "GET", path: "/assets/missing.js", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
||||
{method: "GET", path: "/missing.css", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
||||
{method: "GET", path: "/.well-known/x.txt", status: 200, contentType: "text/plain", contentLength: "10", bodyLength: 10},
|
||||
// The dotfile is not in sirv's FILES and does not match the "any extn"
|
||||
// ignore, so it falls back to index.html.
|
||||
{method: "GET", path: "/.gitkeep", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/.vite/manifest.json", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
||||
{method: "GET", path: "/assets", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "GET", path: "/assets/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
{method: "HEAD", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 0},
|
||||
{
|
||||
method: "GET", path: "/index.html", headerName: "Range", headerValue: "bytes=0-9",
|
||||
status: 206, contentType: html, contentLength: "10", contentRange: "bytes 0-9/100", bodyLength: 10,
|
||||
},
|
||||
{
|
||||
method: "GET", path: "/index.html", headerName: "Accept", headerValue: "application/json",
|
||||
status: 200, contentType: html, contentLength: "100", bodyLength: 100,
|
||||
},
|
||||
{method: "GET", path: "/../go.mod", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
||||
// sirv ignores req.method entirely; app.ts routes /api/** first.
|
||||
{method: "POST", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
// The "any extn" class is ASCII-only, so this is not ignored.
|
||||
{method: "GET", path: "/文件.js", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
||||
} {
|
||||
request := httptest.NewRequest(testCase.method, testCase.path, nil)
|
||||
if testCase.headerName != "" {
|
||||
request.Header.Set(testCase.headerName, testCase.headerValue)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
label := testCase.method + " " + testCase.path
|
||||
if recorder.Code != testCase.status {
|
||||
t.Errorf("%s: status = %d, want %d", label, recorder.Code, testCase.status)
|
||||
}
|
||||
contentType, present := recorder.Header()["Content-Type"]
|
||||
if !present {
|
||||
t.Errorf("%s: Content-Type header is absent", label)
|
||||
} else if len(contentType) != 1 || contentType[0] != testCase.contentType {
|
||||
t.Errorf("%s: Content-Type = %q, want [%q]", label, contentType, testCase.contentType)
|
||||
}
|
||||
if got := recorder.Header().Get("Content-Length"); testCase.contentLength != "" && got != testCase.contentLength {
|
||||
t.Errorf("%s: Content-Length = %q, want %q", label, got, testCase.contentLength)
|
||||
}
|
||||
if got := recorder.Header().Get("Content-Range"); got != testCase.contentRange {
|
||||
t.Errorf("%s: Content-Range = %q, want %q", label, got, testCase.contentRange)
|
||||
}
|
||||
if got := recorder.Body.Len(); got != testCase.bodyLength {
|
||||
t.Errorf("%s: body length = %d, want %d", label, got, testCase.bodyLength)
|
||||
}
|
||||
// sirv never emits either, and a fabricated Last-Modified would add
|
||||
// 304 responses Node never sent.
|
||||
if got := recorder.Header().Get("Last-Modified"); got != "" {
|
||||
t.Errorf("%s: unexpected Last-Modified %q", label, got)
|
||||
}
|
||||
if got := recorder.Header().Get("Cache-Control"); got != "" {
|
||||
t.Errorf("%s: unexpected Cache-Control %q", label, got)
|
||||
}
|
||||
if got := recorder.Header().Get("Etag"); got != "" {
|
||||
t.Errorf("%s: unexpected ETag %q", label, got)
|
||||
}
|
||||
// app.ts setHeaders runs for served files only, not for the JSON 404.
|
||||
wantSecurityHeaders := testCase.status != http.StatusNotFound
|
||||
for header, want := range map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
} {
|
||||
got := recorder.Header().Get(header)
|
||||
if wantSecurityHeaders && got != want {
|
||||
t.Errorf("%s: %s = %q, want %q", label, header, got, want)
|
||||
}
|
||||
if !wantSecurityHeaders && got != "" {
|
||||
t.Errorf("%s: unexpected %s %q on the 404", label, header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerRefusesTraversal(t *testing.T) {
|
||||
handler := staticHandler(staticFixture(), staticNotFound)
|
||||
|
||||
for _, target := range []string{
|
||||
"/../go.mod", "/../../go.mod", "/assets/../../go.mod",
|
||||
"/..%2fgo.mod", "/./../package.json", "//../go.mod",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s: status = %d, want 404", target, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerWithoutIndexHTML(t *testing.T) {
|
||||
// The former frontend mode "none": nothing can be served, everything 404s.
|
||||
handler := staticHandler(fstest.MapFS{".gitkeep": &fstest.MapFile{}}, staticNotFound)
|
||||
|
||||
for _, target := range []string{"/", "/host", "/assets/app.js", "/.gitkeep"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s: status = %d, want 404", target, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLooksLikeFileMatchesSirvIgnore replays the Node oracle for sirv's
|
||||
// "any extn" ignore recorded in evidence/runtime.md section 4.1.
|
||||
func TestLooksLikeFileMatchesSirvIgnore(t *testing.T) {
|
||||
for pathname, want := range map[string]bool{
|
||||
"/": false,
|
||||
"/host": false,
|
||||
"/room/1234": false,
|
||||
"/assets": false,
|
||||
"/assets/": false,
|
||||
"/index.html": true,
|
||||
"/assets/index-abc123.js": true,
|
||||
"/a.b.c.js": true,
|
||||
"/favicon.ico": true,
|
||||
"/my file.txt": true,
|
||||
"/.gitkeep": false,
|
||||
"/x.": false,
|
||||
"/.env": false,
|
||||
"/a.b-c": false,
|
||||
"/文件.js": false,
|
||||
"/café.css": false,
|
||||
"/v1.2/thing": false,
|
||||
} {
|
||||
if got := looksLikeFile(pathname); got != want {
|
||||
t.Errorf("looksLikeFile(%q) = %v, want %v", pathname, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDotSegment(t *testing.T) {
|
||||
for name, want := range map[string]bool{
|
||||
"index.html": false,
|
||||
"assets/app.js": false,
|
||||
".gitkeep": true,
|
||||
".vite/manifest.json": true,
|
||||
"assets/.hidden": true,
|
||||
".well-known/x.txt": false,
|
||||
"deep/.well-known/x.txt": false,
|
||||
"well-known/x.txt": false,
|
||||
".well-knownish/file.txt": true,
|
||||
} {
|
||||
if got := hasDotSegment(name); got != want {
|
||||
t.Errorf("hasDotSegment(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Package config ports src/server/config.ts, src/server/local-config.ts and
|
||||
// src/server/ice.ts: the Hosted environment contract, the Client's local
|
||||
// composition, and the ICE configuration derived from either. It performs no
|
||||
// I/O; the caller supplies the environment as a map.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Environment ports RuntimeEnvironment. DECISIONS D8 renames NODE_ENV to
|
||||
// SCREENER_ENV and drops "test".
|
||||
type Environment string
|
||||
|
||||
// The two accepted SCREENER_ENV values.
|
||||
const (
|
||||
EnvironmentDevelopment Environment = "development"
|
||||
EnvironmentProduction Environment = "production"
|
||||
)
|
||||
|
||||
// Bounds and defaults from src/server/config.ts.
|
||||
const (
|
||||
minSiteAccessPasswordBytes = 8
|
||||
maxSiteAccessPasswordBytes = 128
|
||||
minLiveKitAPISecretBytes = 32
|
||||
defaultMaxViewersPerRoom = 8
|
||||
defaultRoomLeaseSeconds = 86_400
|
||||
defaultPort = 8787
|
||||
maxPort = 65_535
|
||||
)
|
||||
|
||||
// VISIBLE_ASCII_PATTERN.
|
||||
var visibleASCIIPattern = regexp.MustCompile(`^[\x21-\x7e]+$`)
|
||||
|
||||
const removedTurnReason = "ordinary ICE accepts STUN_URLS only"
|
||||
|
||||
// removedEnvironmentVariables ports REMOVED_ENVIRONMENT_VARIABLES together with
|
||||
// the two names the TypeScript checked separately, and NODE_ENV (DECISIONS D8:
|
||||
// a stale unit file must not fail open). An empty reason takes the TURN default.
|
||||
// The order is the report order when several removed names are present.
|
||||
var removedEnvironmentVariables = []struct{ name, reason string }{
|
||||
{"TURN_URLS", ""},
|
||||
{"TURN_SHARED_SECRET", ""},
|
||||
{"TURN_CREDENTIAL_TTL_SECONDS", ""},
|
||||
{"PEER_ICE_TURN_URLS", ""},
|
||||
{"PEER_ICE_TURN_SHARED_SECRET", ""},
|
||||
{"PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", ""},
|
||||
{"SELECTED_EDGE_TURN_URLS", ""},
|
||||
{"SELECTED_EDGE_TURN_SHARED_SECRET", ""},
|
||||
{"SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS", ""},
|
||||
{"SELECTED_EDGE_TURN_ALLOCATION_CAPACITY", ""},
|
||||
{"PEER_ASSISTED_ROOM_IDS", "peer-assisted media applies to every room"},
|
||||
{"PEER_ASSISTED_MEDIA", "peer-assisted media is always enabled"},
|
||||
{"HOST_ADMISSION_PASSWORD", "use SITE_ACCESS_PASSWORD"},
|
||||
{"MAX_PEER_RELAY_DOWNSTREAM_EDGES", "use ENDPOINT_MEDIA_COPY_CAPACITY"},
|
||||
{"ROOM_TTL_SECONDS", "use ROOM_LEASE_SECONDS"},
|
||||
{"ACCESS_PASSWORD", "use SITE_ACCESS_PASSWORD"},
|
||||
{"NODE_ENV", "use SCREENER_ENV"},
|
||||
}
|
||||
|
||||
// LiveKitFallback ports LiveKitFallbackConfig. URL and APIURL are serialised
|
||||
// WHATWG origins.
|
||||
type LiveKitFallback struct {
|
||||
URL string
|
||||
APIURL string
|
||||
APIKey string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
// Config ports ServerConfig. Fields the TypeScript left `undefined` are the
|
||||
// zero value here: an empty SiteAccessPassword means site access is open, an
|
||||
// empty RoomDatabasePath means memory mode, and a nil LiveKit means no SFU
|
||||
// fallback.
|
||||
type Config struct {
|
||||
Env Environment
|
||||
Port int
|
||||
ListenHost string
|
||||
PublicBaseURL *url.URL
|
||||
// AllowedOrigins is a membership test only; Go map order is not observable
|
||||
// here (src/server/app.ts only ever calls `allowedOrigins.has(origin)`).
|
||||
AllowedOrigins map[string]struct{}
|
||||
SiteAccessPassword string
|
||||
RoomDatabasePath string
|
||||
RoomLeaseMs int64
|
||||
MaxViewersPerRoom int
|
||||
EndpointMediaCopyCapacity int
|
||||
LiveKit *LiveKitFallback
|
||||
STUNURLs []string
|
||||
NATPredictionEnabled bool
|
||||
// NATPredictionSTUNURLs is nil where the TypeScript left it `undefined`,
|
||||
// which IceConfig distinguishes from a configured empty list.
|
||||
NATPredictionSTUNURLs []string
|
||||
}
|
||||
|
||||
// Load ports loadConfig. env is the whole process environment: cmd passes
|
||||
// os.Environ() as a map, tests pass a literal. A key present with an empty
|
||||
// value is `""` in TypeScript too, so presence is the only distinction that
|
||||
// needs the two-value lookup.
|
||||
func Load(env map[string]string) (Config, error) {
|
||||
for _, removed := range removedEnvironmentVariables {
|
||||
if _, present := env[removed.name]; present {
|
||||
reason := removed.reason
|
||||
if reason == "" {
|
||||
reason = removedTurnReason
|
||||
}
|
||||
return Config{}, fmt.Errorf("%s is no longer supported; %s", removed.name, reason)
|
||||
}
|
||||
}
|
||||
|
||||
environment, err := parseEnvironment(env)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
port, err := parsePositiveInteger(env["PORT"], defaultPort, "PORT")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if port > maxPort {
|
||||
return Config{}, fmt.Errorf("PORT must be at most %d", maxPort)
|
||||
}
|
||||
listenHost := strings.TrimFunc(env["LISTEN_HOST"], protocol.IsJSWhitespace)
|
||||
if listenHost == "" {
|
||||
listenHost = "0.0.0.0"
|
||||
}
|
||||
|
||||
// TS: environment.PUBLIC_BASE_URL ?? `http://localhost:${port}` — only an
|
||||
// absent variable takes the default.
|
||||
rawPublicBaseURL, present := env["PUBLIC_BASE_URL"]
|
||||
if !present {
|
||||
rawPublicBaseURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
publicBaseURL, err := parseOriginURL(rawPublicBaseURL, "PUBLIC_BASE_URL", "http", "https")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if environment == EnvironmentProduction && publicBaseURL.Scheme != "https" {
|
||||
return Config{}, errors.New("PUBLIC_BASE_URL must use https in production")
|
||||
}
|
||||
|
||||
siteAccessPassword := env["SITE_ACCESS_PASSWORD"]
|
||||
roomDatabasePath, err := parseRoomDatabasePath(env["ROOM_DATABASE_PATH"])
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
stunURLs, err := parseStunURLList(env["STUN_URLS"], "STUN_URLS")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
natPredictionEnabled, err := parseBoolean(env["NAT_PREDICTION_ENABLED"], "NAT_PREDICTION_ENABLED")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if natPredictionEnabled && len(NATPredictionStunURLs(stunURLs)) == 0 {
|
||||
return Config{}, errors.New("NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478")
|
||||
}
|
||||
if natPredictionEnabled &&
|
||||
len(stunURLs)+protocol.MaxNatPredictionAuxiliaryStunURLs > protocol.MaxIceServerURLs {
|
||||
return Config{}, fmt.Errorf(
|
||||
"STUN_URLS must contain at most %d URLs when NAT_PREDICTION_ENABLED=true",
|
||||
protocol.MaxIceServerURLs-protocol.MaxNatPredictionAuxiliaryStunURLs)
|
||||
}
|
||||
maxViewersPerRoom, err := parseBoundedInteger(env["MAX_VIEWERS_PER_ROOM"],
|
||||
defaultMaxViewersPerRoom, "MAX_VIEWERS_PER_ROOM", 1, protocol.MaxViewersPerRoomLimit)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
endpointMediaCopyCapacity, err := parseBoundedInteger(env["ENDPOINT_MEDIA_COPY_CAPACITY"],
|
||||
protocol.DefaultEndpointMediaCopyCapacity, "ENDPOINT_MEDIA_COPY_CAPACITY",
|
||||
1, protocol.MaxEndpointMediaCopyCapacity)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
liveKit, err := parseLiveKitFallback(env, environment)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
secrets := make([]string, 0, 3)
|
||||
if siteAccessPassword != "" {
|
||||
secrets = append(secrets, siteAccessPassword)
|
||||
}
|
||||
if liveKit != nil {
|
||||
secrets = append(secrets, liveKit.APIKey, liveKit.APISecret)
|
||||
}
|
||||
distinct := make(map[string]struct{}, len(secrets))
|
||||
for _, secret := range secrets {
|
||||
distinct[secret] = struct{}{}
|
||||
}
|
||||
if len(distinct) != len(secrets) {
|
||||
return Config{}, errors.New(
|
||||
"SITE_ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values")
|
||||
}
|
||||
// Buffer.byteLength is the UTF-8 length, which is len() in Go. The pattern
|
||||
// already restricts the value to one byte per character.
|
||||
if siteAccessPassword != "" &&
|
||||
(!visibleASCIIPattern.MatchString(siteAccessPassword) ||
|
||||
len(siteAccessPassword) < minSiteAccessPasswordBytes ||
|
||||
len(siteAccessPassword) > maxSiteAccessPasswordBytes) {
|
||||
return Config{}, fmt.Errorf("SITE_ACCESS_PASSWORD must contain %d to %d visible ASCII bytes",
|
||||
minSiteAccessPasswordBytes, maxSiteAccessPasswordBytes)
|
||||
}
|
||||
if environment == EnvironmentProduction && siteAccessPassword == "" {
|
||||
return Config{}, errors.New("SITE_ACCESS_PASSWORD is required in production")
|
||||
}
|
||||
if environment == EnvironmentProduction && len(stunURLs) == 0 {
|
||||
return Config{}, errors.New("STUN is required in production")
|
||||
}
|
||||
|
||||
// ALLOWED_ORIGINS and ROOM_LEASE_SECONDS are validated inside the returned
|
||||
// object literal, i.e. after every check above; keep that order.
|
||||
allowedOrigins, err := parseOrigins(env["ALLOWED_ORIGINS"], Origin(publicBaseURL))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
roomLeaseSeconds, err := parsePositiveInteger(env["ROOM_LEASE_SECONDS"],
|
||||
defaultRoomLeaseSeconds, "ROOM_LEASE_SECONDS")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
Env: environment,
|
||||
Port: int(port),
|
||||
ListenHost: listenHost,
|
||||
PublicBaseURL: publicBaseURL,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
SiteAccessPassword: siteAccessPassword,
|
||||
RoomDatabasePath: roomDatabasePath,
|
||||
RoomLeaseMs: roomLeaseSeconds * 1_000,
|
||||
MaxViewersPerRoom: int(maxViewersPerRoom),
|
||||
EndpointMediaCopyCapacity: int(endpointMediaCopyCapacity),
|
||||
LiveKit: liveKit,
|
||||
STUNURLs: stunURLs,
|
||||
NATPredictionEnabled: natPredictionEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseEnvironment ports parseEnvironment for SCREENER_ENV.
|
||||
func parseEnvironment(env map[string]string) (Environment, error) {
|
||||
// TS: value ?? "development" — a present empty value is not nullish and so
|
||||
// reaches the check below.
|
||||
value, present := env["SCREENER_ENV"]
|
||||
if !present {
|
||||
return EnvironmentDevelopment, nil
|
||||
}
|
||||
switch Environment(value) {
|
||||
case EnvironmentDevelopment, EnvironmentProduction:
|
||||
return Environment(value), nil
|
||||
}
|
||||
return "", errors.New("SCREENER_ENV must be development or production")
|
||||
}
|
||||
|
||||
// parseBoolean ports parseBoolean; every caller used a false fallback.
|
||||
func parseBoolean(value string, name string) (bool, error) {
|
||||
switch value {
|
||||
case "", "false":
|
||||
return false, nil
|
||||
case "true":
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("%s must be true or false", name)
|
||||
}
|
||||
|
||||
// parsePositiveInteger ports parsePositiveInteger, including Number.isSafeInteger.
|
||||
func parsePositiveInteger(value string, fallback int64, name string) (int64, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
number, ok := jsNumber(value)
|
||||
if !ok || number != math.Trunc(number) || number <= 0 || number > protocol.MaxSafeInteger {
|
||||
return 0, fmt.Errorf("%s must be a positive integer", name)
|
||||
}
|
||||
return int64(number), nil
|
||||
}
|
||||
|
||||
// parseBoundedInteger ports parseBoundedInteger.
|
||||
func parseBoundedInteger(value string, fallback int64, name string, minimum, maximum int64) (int64, error) {
|
||||
parsed, err := parsePositiveInteger(value, fallback, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if parsed < minimum || parsed > maximum {
|
||||
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseRoomDatabasePath ports parseRoomDatabasePath. DECISIONS D8: absoluteness
|
||||
// is filepath.IsAbs, which differs from Node's path.isAbsolute on Windows,
|
||||
// where "/var/lib/screener/rooms.sqlite" is relative to the current volume and
|
||||
// is therefore rejected here. Hosted runs on Linux.
|
||||
func parseRoomDatabasePath(value string) (string, error) {
|
||||
path := strings.TrimFunc(value, protocol.IsJSWhitespace)
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if path == ":memory:" || strings.ContainsRune(path, 0) || !filepath.IsAbs(path) {
|
||||
return "", errors.New("ROOM_DATABASE_PATH must be an absolute file path")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// parseLiveKitFallback ports parseLiveKitFallback.
|
||||
func parseLiveKitFallback(env map[string]string, environment Environment) (*LiveKitFallback, error) {
|
||||
rawURL := strings.TrimFunc(env["LIVEKIT_URL"], protocol.IsJSWhitespace)
|
||||
rawAPIURL := strings.TrimFunc(env["LIVEKIT_API_URL"], protocol.IsJSWhitespace)
|
||||
apiKey := strings.TrimFunc(env["LIVEKIT_API_KEY"], protocol.IsJSWhitespace)
|
||||
apiSecret := strings.TrimFunc(env["LIVEKIT_API_SECRET"], protocol.IsJSWhitespace)
|
||||
|
||||
configured := 0
|
||||
for _, value := range []string{rawURL, rawAPIURL, apiKey, apiSecret} {
|
||||
if value != "" {
|
||||
configured++
|
||||
}
|
||||
}
|
||||
if configured == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if configured != 4 {
|
||||
return nil, errors.New(
|
||||
"LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together")
|
||||
}
|
||||
if len(apiSecret) < minLiveKitAPISecretBytes {
|
||||
return nil, fmt.Errorf("LIVEKIT_API_SECRET must contain at least %d bytes", minLiveKitAPISecretBytes)
|
||||
}
|
||||
|
||||
parsedURL, err := parseOriginURL(rawURL, "LIVEKIT_URL", "ws", "wss")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if environment == EnvironmentProduction && parsedURL.Scheme != "wss" {
|
||||
return nil, errors.New("LIVEKIT_URL must use wss in production")
|
||||
}
|
||||
parsedAPIURL, err := parseOriginURL(rawAPIURL, "LIVEKIT_API_URL", "http", "https")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if environment == EnvironmentProduction && parsedAPIURL.Scheme != "https" &&
|
||||
!isLoopbackHostname(parsedAPIURL.Hostname()) {
|
||||
return nil, errors.New("LIVEKIT_API_URL must use https or loopback in production")
|
||||
}
|
||||
return &LiveKitFallback{
|
||||
URL: Origin(parsedURL),
|
||||
APIURL: Origin(parsedAPIURL),
|
||||
APIKey: apiKey,
|
||||
APISecret: apiSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isLoopbackHostname ports isLoopbackHostname. url.URL.Hostname strips the IPv6
|
||||
// brackets, so only the unbracketed "::1" can actually match here.
|
||||
func isLoopbackHostname(hostname string) bool {
|
||||
return hostname == "localhost" || hostname == "127.0.0.1" ||
|
||||
hostname == "[::1]" || hostname == "::1"
|
||||
}
|
||||
|
||||
// parseURLList ports parseUrlList.
|
||||
func parseURLList(value string, name string) ([]string, error) {
|
||||
if strings.TrimFunc(value, protocol.IsJSWhitespace) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
entries := strings.Split(value, ",")
|
||||
list := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
trimmed := strings.TrimFunc(entry, protocol.IsJSWhitespace)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("%s contains an empty URL", name)
|
||||
}
|
||||
list = append(list, trimmed)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// parseStunURLList ports parseStunUrlList. The TypeScript `maximum` parameter
|
||||
// was only ever the default.
|
||||
func parseStunURLList(value string, name string) ([]string, error) {
|
||||
values, err := parseURLList(value, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) > protocol.MaxIceServerURLs {
|
||||
return nil, fmt.Errorf("%s must contain at most %d URLs", name, protocol.MaxIceServerURLs)
|
||||
}
|
||||
for _, value := range values {
|
||||
if !protocol.ValidStunURLValue(value) {
|
||||
return nil, fmt.Errorf("%s contains an invalid STUN URL", name)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// parseOrigins ports parseOrigins.
|
||||
func parseOrigins(value string, fallback string) (map[string]struct{}, error) {
|
||||
origins, err := parseURLList(value, "ALLOWED_ORIGINS")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
origins = []string{fallback}
|
||||
}
|
||||
set := make(map[string]struct{}, len(origins))
|
||||
for _, entry := range origins {
|
||||
origin, err := toOrigin(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set[origin] = struct{}{}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// toOrigin ports toOrigin. The TypeScript let an unparsable entry escape as a
|
||||
// raw TypeError from `new URL`; it fails startup here with a message instead.
|
||||
func toOrigin(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", errors.New("Allowed origins must be valid http or https URLs")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", errors.New("Allowed origins must use http or https")
|
||||
}
|
||||
return Origin(parsed), nil
|
||||
}
|
||||
|
||||
// parseOriginURL ports the `new URL` + protocol + shape ladder that
|
||||
// PUBLIC_BASE_URL, LIVEKIT_URL and LIVEKIT_API_URL each repeat, and returns the
|
||||
// WHATWG-normalised URL (lowercase host, default port removed, path "/").
|
||||
func parseOriginURL(value, name, scheme, alternative string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
// net/url accepts relative references and opaque URLs that the WHATWG
|
||||
// parser rejects (or, for "wss:host", repairs); require both parts.
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("%s must be a valid %s or %s origin", name, scheme, alternative)
|
||||
}
|
||||
if parsed.Scheme != scheme && parsed.Scheme != alternative {
|
||||
return nil, fmt.Errorf("%s must use %s or %s", name, scheme, alternative)
|
||||
}
|
||||
// TS: url.username || url.password || url.pathname !== "/" || url.search ||
|
||||
// url.hash. WHATWG reports "/" for an empty path, which net/url leaves "".
|
||||
if hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") ||
|
||||
parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%s must be an origin without credentials, path, query, or fragment", name)
|
||||
}
|
||||
return &url.URL{Scheme: parsed.Scheme, Host: normalizedHost(parsed), Path: "/"}, nil
|
||||
}
|
||||
|
||||
// hasUserinfo ports `url.username || url.password`: WHATWG reports empty
|
||||
// strings for "https://@host" and "https://:@host", where net/url reports a
|
||||
// non-nil (but empty) Userinfo.
|
||||
func hasUserinfo(parsed *url.URL) bool {
|
||||
if parsed.User == nil {
|
||||
return false
|
||||
}
|
||||
password, _ := parsed.User.Password()
|
||||
return parsed.User.Username() != "" || password != ""
|
||||
}
|
||||
|
||||
// Origin ports the WHATWG URL `origin` getter for the http/https/ws/wss schemes
|
||||
// this package accepts: new URL("https://Example.com:443/").origin is
|
||||
// "https://example.com". It is exported so app and cmd can compare request
|
||||
// origins without a second copy of the rule.
|
||||
func Origin(parsed *url.URL) string {
|
||||
return parsed.Scheme + "://" + normalizedHost(parsed)
|
||||
}
|
||||
|
||||
// normalizedHost lowercases the host, keeps an IPv6 literal bracketed and drops
|
||||
// a port equal to the scheme default. Non-ASCII hosts are not IDNA-encoded, so
|
||||
// they keep the form they were configured with.
|
||||
func normalizedHost(parsed *url.URL) string {
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
// WHATWG parses the port as a number, so "0443" and "443" are both default.
|
||||
if number, err := strconv.Atoi(parsed.Port()); err == nil {
|
||||
if port := strconv.Itoa(number); port != defaultSchemePort(parsed.Scheme) {
|
||||
host += ":" + port
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func defaultSchemePort(scheme string) string {
|
||||
switch scheme {
|
||||
case "http", "ws":
|
||||
return "80"
|
||||
case "https", "wss":
|
||||
return "443"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// decimalNumberPattern is the StrDecimalLiteral grammar Number(string) accepts.
|
||||
var decimalNumberPattern = regexp.MustCompile(
|
||||
`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`)
|
||||
|
||||
// jsNumber ports Number(string). The second result is false where JavaScript
|
||||
// yields NaN; values JavaScript renders as Infinity are reported as NaN too,
|
||||
// because every caller rejects them through Number.isSafeInteger anyway.
|
||||
func jsNumber(value string) (float64, bool) {
|
||||
text := strings.TrimFunc(value, protocol.IsJSWhitespace)
|
||||
if text == "" {
|
||||
return 0, true // Number("") and Number(" ") are 0.
|
||||
}
|
||||
if len(text) > 2 && text[0] == '0' {
|
||||
if base := numericBase(text[1]); base != 0 {
|
||||
number, err := strconv.ParseUint(text[2:], base, 64)
|
||||
return float64(number), err == nil
|
||||
}
|
||||
}
|
||||
if !decimalNumberPattern.MatchString(text) {
|
||||
return 0, false
|
||||
}
|
||||
number, err := strconv.ParseFloat(text, 64)
|
||||
return number, err == nil
|
||||
}
|
||||
|
||||
func numericBase(marker byte) int {
|
||||
switch marker {
|
||||
case 'x', 'X':
|
||||
return 16
|
||||
case 'o', 'O':
|
||||
return 8
|
||||
case 'b', 'B':
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from tests/server-config.test.ts.
|
||||
|
||||
// env merges the given maps, later keys winning, so a case can extend a base.
|
||||
func env(parts ...map[string]string) map[string]string {
|
||||
merged := map[string]string{}
|
||||
for _, part := range parts {
|
||||
maps.Copy(merged, part)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
var (
|
||||
liveKitAdmission = map[string]string{"LIVEKIT_API_URL": "https://livekit-api.test"}
|
||||
liveKitSecret = strings.Repeat("s", 32)
|
||||
productionBase = map[string]string{
|
||||
"SCREENER_ENV": "production",
|
||||
"PUBLIC_BASE_URL": "https://share.test",
|
||||
"SITE_ACCESS_PASSWORD": "host-password-12",
|
||||
"STUN_URLS": "stun:stun.test:3478",
|
||||
}
|
||||
)
|
||||
|
||||
func stunList(count int, prefix string) string {
|
||||
urls := make([]string, 0, count)
|
||||
for index := range count {
|
||||
urls = append(urls, fmt.Sprintf("stun:%s-%d.test:3478", prefix, index))
|
||||
}
|
||||
return strings.Join(urls, ",")
|
||||
}
|
||||
|
||||
func mustLoad(t *testing.T, environment map[string]string) Config {
|
||||
t.Helper()
|
||||
config, err := Load(environment)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: unexpected error %v", err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func TestLoadDevelopmentDefaults(t *testing.T) {
|
||||
config := mustLoad(t, map[string]string{"SCREENER_ENV": "development", "PORT": "9123"})
|
||||
|
||||
if config.Env != EnvironmentDevelopment {
|
||||
t.Errorf("Env = %q", config.Env)
|
||||
}
|
||||
if config.ListenHost != "0.0.0.0" {
|
||||
t.Errorf("ListenHost = %q", config.ListenHost)
|
||||
}
|
||||
if got := config.PublicBaseURL.String(); got != "http://localhost:9123/" {
|
||||
t.Errorf("PublicBaseURL = %q", got)
|
||||
}
|
||||
want := map[string]struct{}{"http://localhost:9123": {}}
|
||||
if !reflect.DeepEqual(config.AllowedOrigins, want) {
|
||||
t.Errorf("AllowedOrigins = %v", config.AllowedOrigins)
|
||||
}
|
||||
if config.RoomDatabasePath != "" {
|
||||
t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath)
|
||||
}
|
||||
if len(config.STUNURLs) != 0 {
|
||||
t.Errorf("STUNURLs = %v", config.STUNURLs)
|
||||
}
|
||||
if config.NATPredictionEnabled {
|
||||
t.Error("NATPredictionEnabled = true")
|
||||
}
|
||||
if config.NATPredictionSTUNURLs != nil {
|
||||
t.Errorf("NATPredictionSTUNURLs = %v, want nil (TS left it undefined)", config.NATPredictionSTUNURLs)
|
||||
}
|
||||
if config.MaxViewersPerRoom != 8 {
|
||||
t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom)
|
||||
}
|
||||
if config.EndpointMediaCopyCapacity != 2 {
|
||||
t.Errorf("EndpointMediaCopyCapacity = %d", config.EndpointMediaCopyCapacity)
|
||||
}
|
||||
if config.LiveKit != nil {
|
||||
t.Errorf("LiveKit = %+v", config.LiveKit)
|
||||
}
|
||||
if config.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
|
||||
}
|
||||
if config.SiteAccessPassword != "" {
|
||||
t.Error("SiteAccessPassword is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLiveKitTuple(t *testing.T) {
|
||||
config := mustLoad(t, map[string]string{
|
||||
"LIVEKIT_URL": " ws://livekit.test:7880 ",
|
||||
"LIVEKIT_API_URL": " http://livekit.test:7880 ",
|
||||
"LIVEKIT_API_KEY": " test-key ",
|
||||
"LIVEKIT_API_SECRET": " " + liveKitSecret + " ",
|
||||
})
|
||||
want := LiveKitFallback{
|
||||
URL: "ws://livekit.test:7880",
|
||||
APIURL: "http://livekit.test:7880",
|
||||
APIKey: "test-key",
|
||||
APISecret: liveKitSecret,
|
||||
}
|
||||
if config.LiveKit == nil || *config.LiveKit != want {
|
||||
t.Errorf("LiveKit = %+v, want %+v", config.LiveKit, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAccepts(t *testing.T) {
|
||||
developmentPath, err := filepath.Abs(filepath.Join("state", "rooms.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs: %v", err)
|
||||
}
|
||||
productionPath, err := filepath.Abs(filepath.Join("production-state", "rooms.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
check func(*testing.T, Config)
|
||||
}{
|
||||
{"default viewer limit", map[string]string{}, func(t *testing.T, c Config) {
|
||||
if c.MaxViewersPerRoom != 8 {
|
||||
t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom)
|
||||
}
|
||||
}},
|
||||
{"viewer limit lower boundary", map[string]string{"MAX_VIEWERS_PER_ROOM": "1"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.MaxViewersPerRoom != 1 {
|
||||
t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom)
|
||||
}
|
||||
}},
|
||||
{"viewer limit ceiling",
|
||||
map[string]string{"MAX_VIEWERS_PER_ROOM": strconv.Itoa(protocol.MaxViewersPerRoomLimit)},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.MaxViewersPerRoom != protocol.MaxViewersPerRoomLimit {
|
||||
t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom)
|
||||
}
|
||||
}},
|
||||
{"copy capacity 1", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.EndpointMediaCopyCapacity != 1 {
|
||||
t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity)
|
||||
}
|
||||
}},
|
||||
{"copy capacity 2", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "2"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.EndpointMediaCopyCapacity != 2 {
|
||||
t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity)
|
||||
}
|
||||
}},
|
||||
{"copy capacity 3", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "3"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.EndpointMediaCopyCapacity != 3 {
|
||||
t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity)
|
||||
}
|
||||
}},
|
||||
{"explicit loopback listen host", map[string]string{"LISTEN_HOST": " 127.0.0.1 "},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.ListenHost != "127.0.0.1" {
|
||||
t.Errorf("ListenHost = %q", c.ListenHost)
|
||||
}
|
||||
}},
|
||||
{"production STUN", productionBase, func(t *testing.T, c Config) {
|
||||
if !slices.Equal(c.STUNURLs, []string{"stun:stun.test:3478"}) {
|
||||
t.Errorf("STUNURLs = %v", c.STUNURLs)
|
||||
}
|
||||
}},
|
||||
{"production site access password",
|
||||
env(productionBase, map[string]string{"SITE_ACCESS_PASSWORD": "easy-key"}),
|
||||
func(t *testing.T, c Config) {
|
||||
if c.SiteAccessPassword != "easy-key" {
|
||||
t.Errorf("SiteAccessPassword = %q", c.SiteAccessPassword)
|
||||
}
|
||||
}},
|
||||
{"STUN-only production with LiveKit", env(productionBase, liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test",
|
||||
"LIVEKIT_API_KEY": "test-key",
|
||||
"LIVEKIT_API_SECRET": liveKitSecret,
|
||||
}), func(t *testing.T, c Config) {
|
||||
if c.LiveKit == nil || c.LiveKit.URL != "wss://livekit.test" {
|
||||
t.Errorf("LiveKit = %+v", c.LiveKit)
|
||||
}
|
||||
}},
|
||||
{"production loopback LiveKit control", env(productionBase, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test",
|
||||
"LIVEKIT_API_URL": "http://127.0.0.1:7880",
|
||||
"LIVEKIT_API_KEY": "test-key",
|
||||
"LIVEKIT_API_SECRET": liveKitSecret,
|
||||
}), func(t *testing.T, c Config) {
|
||||
if c.LiveKit == nil || c.LiveKit.APIURL != "http://127.0.0.1:7880" {
|
||||
t.Errorf("LiveKit = %+v", c.LiveKit)
|
||||
}
|
||||
}},
|
||||
{"uppercase STUN scheme and IPv6 host", map[string]string{"STUN_URLS": "STUN:[2001:db8::1]:3478"},
|
||||
func(t *testing.T, c Config) {
|
||||
if !slices.Equal(c.STUNURLs, []string{"STUN:[2001:db8::1]:3478"}) {
|
||||
t.Errorf("STUNURLs = %v", c.STUNURLs)
|
||||
}
|
||||
}},
|
||||
{"NAT prediction with a base listener", map[string]string{
|
||||
"STUN_URLS": "stun:share.test:3478",
|
||||
"NAT_PREDICTION_ENABLED": "true",
|
||||
}, func(t *testing.T, c Config) {
|
||||
if !c.NATPredictionEnabled {
|
||||
t.Error("NATPredictionEnabled = false")
|
||||
}
|
||||
}},
|
||||
{"room lease default", map[string]string{}, func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
{"room lease override", map[string]string{"ROOM_LEASE_SECONDS": "3600"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 3_600_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
{"file-backed room authority in development",
|
||||
map[string]string{"ROOM_DATABASE_PATH": " " + developmentPath + " "},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomDatabasePath != developmentPath {
|
||||
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
|
||||
}
|
||||
}},
|
||||
{"blank room database path", map[string]string{"ROOM_DATABASE_PATH": ""},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomDatabasePath != "" {
|
||||
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
|
||||
}
|
||||
}},
|
||||
{"file-backed room authority in production",
|
||||
env(productionBase, map[string]string{"ROOM_DATABASE_PATH": productionPath}),
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomDatabasePath != productionPath {
|
||||
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
|
||||
}
|
||||
}},
|
||||
{"allowed origins are normalised",
|
||||
map[string]string{"ALLOWED_ORIGINS": " https://Example.com:443/path , http://a.test:80 "},
|
||||
func(t *testing.T, c Config) {
|
||||
want := map[string]struct{}{"https://example.com": {}, "http://a.test": {}}
|
||||
if !reflect.DeepEqual(c.AllowedOrigins, want) {
|
||||
t.Errorf("AllowedOrigins = %v", c.AllowedOrigins)
|
||||
}
|
||||
}},
|
||||
// Number(string) coercion, which the TS parsers inherited.
|
||||
{"port with surrounding whitespace", map[string]string{"PORT": " 9123 "},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.Port != 9123 {
|
||||
t.Errorf("Port = %d", c.Port)
|
||||
}
|
||||
}},
|
||||
{"hexadecimal port", map[string]string{"PORT": "0x1F5B"}, func(t *testing.T, c Config) {
|
||||
if c.Port != 8027 {
|
||||
t.Errorf("Port = %d", c.Port)
|
||||
}
|
||||
}},
|
||||
{"exponent room lease", map[string]string{"ROOM_LEASE_SECONDS": "1e3"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 1_000_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
testCase.check(t, mustLoad(t, testCase.env))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want string
|
||||
}{
|
||||
// Partial LiveKit credential tuples.
|
||||
{"only LiveKit URL", map[string]string{"LIVEKIT_URL": "wss://livekit.test"},
|
||||
"LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together"},
|
||||
{"only LiveKit control URL", liveKitAdmission,
|
||||
"LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together"},
|
||||
{"only LiveKit key", map[string]string{"LIVEKIT_API_KEY": "test-key"},
|
||||
"must be configured together"},
|
||||
{"only LiveKit secret", map[string]string{"LIVEKIT_API_SECRET": liveKitSecret},
|
||||
"must be configured together"},
|
||||
{"LiveKit without secret", env(liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": "test-key",
|
||||
}), "must be configured together"},
|
||||
|
||||
// Invalid LiveKit media origins.
|
||||
{"LiveKit https scheme", liveKitURL("https://livekit.test"), "LIVEKIT_URL must use ws or wss"},
|
||||
{"LiveKit credentials", liveKitURL("wss://user:pass@livekit.test"),
|
||||
"LIVEKIT_URL must be an origin without credentials, path, query, or fragment"},
|
||||
{"LiveKit path", liveKitURL("wss://livekit.test/rtc"), "LIVEKIT_URL must be an origin"},
|
||||
{"LiveKit query", liveKitURL("wss://livekit.test?token=value"), "LIVEKIT_URL must be an origin"},
|
||||
{"LiveKit fragment", liveKitURL("wss://livekit.test#fragment"), "LIVEKIT_URL must be an origin"},
|
||||
{"LiveKit not a URL", liveKitURL("livekit.test"), "LIVEKIT_URL must be a valid ws or wss origin"},
|
||||
{"LiveKit ws in production", env(productionBase, liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": "ws://livekit.test:7880", "LIVEKIT_API_KEY": "test-key",
|
||||
"LIVEKIT_API_SECRET": liveKitSecret,
|
||||
}), "LIVEKIT_URL must use wss in production"},
|
||||
|
||||
// Invalid LiveKit control origins.
|
||||
{"control ws scheme", liveKitAPIURL("ws://livekit-api.test"), "LIVEKIT_API_URL must use http or https"},
|
||||
{"control credentials", liveKitAPIURL("http://user:pass@livekit-api.test"),
|
||||
"LIVEKIT_API_URL must be an origin without credentials, path, query, or fragment"},
|
||||
{"control path", liveKitAPIURL("http://livekit-api.test/rtc"), "LIVEKIT_API_URL must be an origin"},
|
||||
{"control query", liveKitAPIURL("http://livekit-api.test?token=value"), "LIVEKIT_API_URL must be an origin"},
|
||||
{"control fragment", liveKitAPIURL("http://livekit-api.test#fragment"), "LIVEKIT_API_URL must be an origin"},
|
||||
{"control not a URL", liveKitAPIURL("livekit-api.test"),
|
||||
"LIVEKIT_API_URL must be a valid http or https origin"},
|
||||
{"control plaintext in production", env(productionBase, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_URL": "http://livekit-api.test:7880",
|
||||
"LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret,
|
||||
}), "must use https or loopback"},
|
||||
{"short LiveKit secret", env(liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": "test-key",
|
||||
"LIVEKIT_API_SECRET": "too-short",
|
||||
}), "LIVEKIT_API_SECRET must contain at least 32 bytes"},
|
||||
|
||||
// Reused infrastructure secrets.
|
||||
{"site password reused as LiveKit secret", env(liveKitAdmission, map[string]string{
|
||||
"SITE_ACCESS_PASSWORD": strings.Repeat("x", 32), "LIVEKIT_URL": "wss://livekit.test",
|
||||
"LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": strings.Repeat("x", 32),
|
||||
}), "must use independent values"},
|
||||
{"LiveKit key reused as LiveKit secret", env(liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": strings.Repeat("x", 32),
|
||||
"LIVEKIT_API_SECRET": strings.Repeat("x", 32),
|
||||
}), "must use independent values"},
|
||||
|
||||
// Bounded integers.
|
||||
{"viewer limit above ceiling",
|
||||
map[string]string{"MAX_VIEWERS_PER_ROOM": strconv.Itoa(protocol.MaxViewersPerRoomLimit + 1)},
|
||||
"MAX_VIEWERS_PER_ROOM must be between 1 and 20"},
|
||||
{"viewer limit zero", map[string]string{"MAX_VIEWERS_PER_ROOM": "0"},
|
||||
"MAX_VIEWERS_PER_ROOM must be a positive integer"},
|
||||
{"fractional viewer limit", map[string]string{"MAX_VIEWERS_PER_ROOM": "1.5"},
|
||||
"MAX_VIEWERS_PER_ROOM must be a positive integer"},
|
||||
{"copy capacity zero", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "0"},
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"},
|
||||
{"copy capacity above ceiling", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "4"},
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY must be between 1 and 3"},
|
||||
{"fractional copy capacity", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1.5"},
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"},
|
||||
{"zero room lease", map[string]string{"ROOM_LEASE_SECONDS": "0"},
|
||||
"ROOM_LEASE_SECONDS must be a positive integer"},
|
||||
{"port zero", map[string]string{"PORT": "0"}, "PORT must be a positive integer"},
|
||||
{"port not a number", map[string]string{"PORT": "eight"}, "PORT must be a positive integer"},
|
||||
{"port above the TCP ceiling", map[string]string{"PORT": "65536"}, "PORT must be at most 65535"},
|
||||
|
||||
// Public base URL.
|
||||
{"public base URL credentials", map[string]string{"PUBLIC_BASE_URL": "https://user:pass@share.test"},
|
||||
"PUBLIC_BASE_URL must be an origin"},
|
||||
{"public base URL path", map[string]string{"PUBLIC_BASE_URL": "https://share.test/path"},
|
||||
"PUBLIC_BASE_URL must be an origin"},
|
||||
{"public base URL query", map[string]string{"PUBLIC_BASE_URL": "https://share.test?query=1"},
|
||||
"PUBLIC_BASE_URL must be an origin"},
|
||||
{"public base URL fragment", map[string]string{"PUBLIC_BASE_URL": "https://share.test#fragment"},
|
||||
"PUBLIC_BASE_URL must be an origin"},
|
||||
{"public base URL scheme", map[string]string{"PUBLIC_BASE_URL": "ftp://share.test"},
|
||||
"PUBLIC_BASE_URL must use http or https"},
|
||||
{"blank public base URL", map[string]string{"PUBLIC_BASE_URL": ""},
|
||||
"PUBLIC_BASE_URL must be a valid http or https origin"},
|
||||
{"plaintext public base URL in production", env(productionBase, map[string]string{
|
||||
"PUBLIC_BASE_URL": "http://share.test",
|
||||
}), "PUBLIC_BASE_URL must use https in production"},
|
||||
|
||||
// Allowed origins.
|
||||
{"allowed origin scheme", map[string]string{"ALLOWED_ORIGINS": "ftp://share.test"},
|
||||
"Allowed origins must use http or https"},
|
||||
{"unparsable allowed origin", map[string]string{"ALLOWED_ORIGINS": "share.test"},
|
||||
"Allowed origins must be valid http or https URLs"},
|
||||
{"empty allowed origin entry", map[string]string{"ALLOWED_ORIGINS": "https://a.test,,https://b.test"},
|
||||
"ALLOWED_ORIGINS contains an empty URL"},
|
||||
|
||||
// Production requirements.
|
||||
{"production without STUN", map[string]string{
|
||||
"SCREENER_ENV": "production", "PUBLIC_BASE_URL": "https://share.test",
|
||||
"SITE_ACCESS_PASSWORD": "host-password-12",
|
||||
}, "STUN is required in production"},
|
||||
{"production without a site password", map[string]string{
|
||||
"SCREENER_ENV": "production", "PUBLIC_BASE_URL": "https://share.test",
|
||||
"STUN_URLS": "stun:stun.test:3478",
|
||||
}, "SITE_ACCESS_PASSWORD is required in production"},
|
||||
|
||||
// Site access password bounds (UTF-8 bytes).
|
||||
{"site password too short", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 7)},
|
||||
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
|
||||
{"site password outside ASCII", map[string]string{"SITE_ACCESS_PASSWORD": "密码密码密码密码"},
|
||||
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
|
||||
{"site password with spaces", map[string]string{"SITE_ACCESS_PASSWORD": "contains spaces"},
|
||||
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
|
||||
{"site password too long", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 129)},
|
||||
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
|
||||
|
||||
// STUN lists.
|
||||
{"STUN path", map[string]string{"STUN_URLS": "stun:stun.test/path"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN query", map[string]string{"STUN_URLS": "stun:stun.test?transport=udp"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN fragment", map[string]string{"STUN_URLS": "stun:stun.test#fragment"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN without authority", map[string]string{"STUN_URLS": "stun:"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN with an empty port", map[string]string{"STUN_URLS": "stun:stun.test:"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN port zero", map[string]string{"STUN_URLS": "stun:stun.test:0"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUN userinfo", map[string]string{"STUN_URLS": "stun:user@stun.test:3478"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"TURN scheme", map[string]string{"STUN_URLS": "turn:turn.test:3478"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"STUNS scheme", map[string]string{"STUN_URLS": "stuns:stun.test:5349"},
|
||||
"STUN_URLS contains an invalid STUN URL"},
|
||||
{"empty STUN entry", map[string]string{"STUN_URLS": "stun:a.test:3478,,stun:b.test:3478"},
|
||||
"STUN_URLS contains an empty URL"},
|
||||
{"too many STUN URLs", map[string]string{"STUN_URLS": stunList(9, "stun")},
|
||||
"STUN_URLS must contain at most 8 URLs"},
|
||||
{"no NAT prediction slots", map[string]string{
|
||||
"STUN_URLS": stunList(7, "ordinary"), "NAT_PREDICTION_ENABLED": "true",
|
||||
}, "STUN_URLS must contain at most 6 URLs when NAT_PREDICTION_ENABLED=true"},
|
||||
|
||||
// NAT prediction.
|
||||
{"NAT prediction without STUN", map[string]string{"NAT_PREDICTION_ENABLED": "true"},
|
||||
"NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"},
|
||||
{"NAT prediction off the base port", map[string]string{
|
||||
"STUN_URLS": "stun:share.test:5349", "NAT_PREDICTION_ENABLED": "true",
|
||||
}, "NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"},
|
||||
{"NAT prediction not a boolean", map[string]string{"NAT_PREDICTION_ENABLED": "sometimes"},
|
||||
"NAT_PREDICTION_ENABLED must be true or false"},
|
||||
|
||||
// Room database path.
|
||||
{"memory room database path", map[string]string{"ROOM_DATABASE_PATH": ":memory:"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
{"room database path with a NUL", map[string]string{"ROOM_DATABASE_PATH": "rooms\x00.sqlite"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
{"bare room database file name", map[string]string{"ROOM_DATABASE_PATH": "rooms.sqlite"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
{"relative room database path", map[string]string{"ROOM_DATABASE_PATH": "./state/rooms.sqlite"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
|
||||
// Runtime environment (DECISIONS D8).
|
||||
{"NODE_ENV is removed", map[string]string{"NODE_ENV": "production"},
|
||||
"NODE_ENV is no longer supported; use SCREENER_ENV"},
|
||||
{"blank NODE_ENV is removed", map[string]string{"NODE_ENV": ""},
|
||||
"NODE_ENV is no longer supported; use SCREENER_ENV"},
|
||||
{"test environment is gone", map[string]string{"SCREENER_ENV": "test"},
|
||||
"SCREENER_ENV must be development or production"},
|
||||
{"blank environment", map[string]string{"SCREENER_ENV": ""},
|
||||
"SCREENER_ENV must be development or production"},
|
||||
|
||||
// Removed variables carrying their own replacement.
|
||||
{"removed relay downstream setting", map[string]string{"MAX_PEER_RELAY_DOWNSTREAM_EDGES": "2"},
|
||||
"MAX_PEER_RELAY_DOWNSTREAM_EDGES is no longer supported; use ENDPOINT_MEDIA_COPY_CAPACITY"},
|
||||
{"removed room rollout setting", map[string]string{"PEER_ASSISTED_ROOM_IDS": "1"},
|
||||
"PEER_ASSISTED_ROOM_IDS is no longer supported; peer-assisted media applies to every room"},
|
||||
{"removed peer-assisted toggle", map[string]string{"PEER_ASSISTED_MEDIA": "true"},
|
||||
"PEER_ASSISTED_MEDIA is no longer supported; peer-assisted media is always enabled"},
|
||||
{"removed room TTL", map[string]string{"ROOM_TTL_SECONDS": ""},
|
||||
"ROOM_TTL_SECONDS is no longer supported; use ROOM_LEASE_SECONDS"},
|
||||
{"removed access password", map[string]string{"ACCESS_PASSWORD": "legacy-password"},
|
||||
"ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
|
||||
{"blank access password", map[string]string{"ACCESS_PASSWORD": ""},
|
||||
"ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
|
||||
{"removed host admission password", map[string]string{"HOST_ADMISSION_PASSWORD": "legacy-password"},
|
||||
"HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
|
||||
{"blank host admission password", map[string]string{"HOST_ADMISSION_PASSWORD": ""},
|
||||
"HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := Load(testCase.env)
|
||||
if err == nil {
|
||||
t.Fatalf("Load: want error containing %q", testCase.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Errorf("Load: error %q, want it to contain %q", err, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsRemovedTurnVariables covers the ten TURN names that share the
|
||||
// default reason, blank included.
|
||||
func TestLoadRejectsRemovedTurnVariables(t *testing.T) {
|
||||
names := []string{
|
||||
"TURN_URLS", "TURN_SHARED_SECRET", "TURN_CREDENTIAL_TTL_SECONDS",
|
||||
"PEER_ICE_TURN_URLS", "PEER_ICE_TURN_SHARED_SECRET",
|
||||
"PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", "SELECTED_EDGE_TURN_URLS",
|
||||
"SELECTED_EDGE_TURN_SHARED_SECRET", "SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS",
|
||||
"SELECTED_EDGE_TURN_ALLOCATION_CAPACITY",
|
||||
}
|
||||
for _, name := range names {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Load(map[string]string{name: ""})
|
||||
want := name + " is no longer supported; ordinary ICE accepts STUN_URLS only"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("Load: error %v, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrigin(t *testing.T) {
|
||||
cases := []struct{ raw, scheme, alternative, want string }{
|
||||
{"https://Example.com:443/", "http", "https", "https://example.com"},
|
||||
{"http://Example.com:80", "http", "https", "http://example.com"},
|
||||
{"https://share.test:0443", "http", "https", "https://share.test"},
|
||||
{"http://localhost:9123", "http", "https", "http://localhost:9123"},
|
||||
{"wss://LiveKit.test:443", "ws", "wss", "wss://livekit.test"},
|
||||
{"ws://livekit.test:7880", "ws", "wss", "ws://livekit.test:7880"},
|
||||
{"https://[2001:DB8::1]:8443", "http", "https", "https://[2001:db8::1]:8443"},
|
||||
{"http://[::1]:7880", "http", "https", "http://[::1]:7880"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.raw, func(t *testing.T) {
|
||||
parsed, err := parseOriginURL(testCase.raw, "TEST", testCase.scheme, testCase.alternative)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOriginURL: %v", err)
|
||||
}
|
||||
if got := Origin(parsed); got != testCase.want {
|
||||
t.Errorf("Origin = %q, want %q", got, testCase.want)
|
||||
}
|
||||
if got := parsed.String(); got != testCase.want+"/" {
|
||||
t.Errorf("String = %q, want %q", got, testCase.want+"/")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func liveKitURL(value string) map[string]string {
|
||||
return env(liveKitAdmission, map[string]string{
|
||||
"LIVEKIT_URL": value, "LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret,
|
||||
})
|
||||
}
|
||||
|
||||
func liveKitAPIURL(value string) map[string]string {
|
||||
return map[string]string{
|
||||
"LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_URL": value,
|
||||
"LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from src/server/ice.ts.
|
||||
const natPredictionBasePort = 3478
|
||||
|
||||
// natPredictionAuxiliaryPorts are NAT_PREDICTION_AUXILIARY_PORTS: the two
|
||||
// listeners a NAT-prediction STUN deployment binds beside the base port, whose
|
||||
// answers reveal the peer's port-allocation stride.
|
||||
var natPredictionAuxiliaryPorts = [...]int{natPredictionBasePort + 1, natPredictionBasePort + 2}
|
||||
|
||||
// NATPredictionStunURLs ports natPredictionStunUrls: the two auxiliary
|
||||
// listeners derived from the first ordinary STUN authority on UDP 3478.
|
||||
func NATPredictionStunURLs(stunURLs []string) []string {
|
||||
for _, stunURL := range stunURLs {
|
||||
// TS: stunUrl.slice(stunUrl.indexOf(":") + 1) — a URL without a colon
|
||||
// keeps its whole text, which then fails to yield a host below.
|
||||
authorityText := stunURL[strings.IndexByte(stunURL, ':')+1:]
|
||||
authority, err := url.Parse("http://" + authorityText)
|
||||
if err != nil || authority.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
port := authority.Port()
|
||||
// The WHATWG parser drops a port equal to the scheme default, so
|
||||
// "stun:host:80" reaches the TypeScript's `: NAT_PREDICTION_BASE_PORT`
|
||||
// fallback and counts as a base listener.
|
||||
if port == defaultSchemePort("http") {
|
||||
port = ""
|
||||
}
|
||||
if port != "" {
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number != natPredictionBasePort {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// WHATWG lowercases the host and keeps an IPv6 literal bracketed;
|
||||
// url.URL.Hostname strips the brackets, so put them back.
|
||||
hostname := strings.ToLower(authority.Hostname())
|
||||
if strings.Contains(hostname, ":") {
|
||||
hostname = "[" + hostname + "]"
|
||||
}
|
||||
derived := make([]string, 0, len(natPredictionAuxiliaryPorts))
|
||||
for _, auxiliaryPort := range natPredictionAuxiliaryPorts {
|
||||
derived = append(derived, fmt.Sprintf("stun:%s:%d", hostname, auxiliaryPort))
|
||||
}
|
||||
return derived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IceConfig ports createIceConfig.
|
||||
func IceConfig(c Config) protocol.IceConfig {
|
||||
// TS: options.natPredictionStunUrls ?? natPredictionStunUrls(stunUrls). A
|
||||
// configured empty list (Local without prediction) is kept as-is; only an
|
||||
// absent one is derived, so nil and empty differ here.
|
||||
prediction := c.NATPredictionSTUNURLs
|
||||
if prediction == nil {
|
||||
prediction = NATPredictionStunURLs(c.STUNURLs)
|
||||
}
|
||||
config := protocol.IceConfig{
|
||||
IceServers: []protocol.IceServer{},
|
||||
NatPredictionStunURLs: []string{},
|
||||
}
|
||||
if len(c.STUNURLs) > 0 {
|
||||
config.IceServers = []protocol.IceServer{
|
||||
{URLs: protocol.IceServerURLList(append([]string{}, c.STUNURLs...)...)},
|
||||
}
|
||||
}
|
||||
if c.NATPredictionEnabled {
|
||||
config.NatPredictionStunURLs = append([]string{}, prediction...)
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from tests/ice.test.ts.
|
||||
|
||||
func TestIceConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "returns only the configured STUN servers",
|
||||
config: Config{
|
||||
STUNURLs: []string{"stun:stun-a.example.test:3478", "stun:stun-b.example.test:3478"},
|
||||
NATPredictionEnabled: true,
|
||||
NATPredictionSTUNURLs: []string{
|
||||
"stun:survey-a.example.test:3478", "stun:survey-b.example.test:3478",
|
||||
},
|
||||
},
|
||||
want: `{"iceServers":[{"urls":["stun:stun-a.example.test:3478",` +
|
||||
`"stun:stun-b.example.test:3478"]}],"natPredictionStunUrls":` +
|
||||
`["stun:survey-a.example.test:3478","stun:survey-b.example.test:3478"]}`,
|
||||
},
|
||||
{
|
||||
name: "keeps local development explicit when STUN is absent",
|
||||
config: Config{},
|
||||
want: `{"iceServers":[],"natPredictionStunUrls":[]}`,
|
||||
},
|
||||
{
|
||||
name: "does not expose auxiliary listeners when the capability is disabled",
|
||||
config: Config{STUNURLs: []string{"stun:share.example.test:3478"}},
|
||||
want: `{"iceServers":[{"urls":["stun:share.example.test:3478"]}],` +
|
||||
`"natPredictionStunUrls":[]}`,
|
||||
},
|
||||
{
|
||||
name: "derives auxiliary listeners from an IPv6 STUN authority",
|
||||
config: Config{
|
||||
STUNURLs: []string{"STUN:[2001:db8::1]:3478"},
|
||||
NATPredictionEnabled: true,
|
||||
},
|
||||
want: `{"iceServers":[{"urls":["STUN:[2001:db8::1]:3478"]}],` +
|
||||
`"natPredictionStunUrls":["stun:[2001:db8::1]:3479","stun:[2001:db8::1]:3480"]}`,
|
||||
},
|
||||
{
|
||||
// TS: `options.natPredictionStunUrls ?? natPredictionStunUrls(...)`
|
||||
// keeps a configured empty list instead of deriving one.
|
||||
name: "keeps a configured empty prediction list",
|
||||
config: Config{
|
||||
STUNURLs: []string{"stun:share.example.test:3478"},
|
||||
NATPredictionEnabled: true,
|
||||
NATPredictionSTUNURLs: []string{},
|
||||
},
|
||||
want: `{"iceServers":[{"urls":["stun:share.example.test:3478"]}],` +
|
||||
`"natPredictionStunUrls":[]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
encoded, err := json.Marshal(IceConfig(testCase.config))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if string(encoded) != testCase.want {
|
||||
t.Errorf("IceConfig = %s, want %s", encoded, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIceConfigDoesNotAliasTheConfiguration(t *testing.T) {
|
||||
config := Config{
|
||||
STUNURLs: []string{"stun:a.test:3478"},
|
||||
NATPredictionEnabled: true,
|
||||
NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"},
|
||||
}
|
||||
derived := IceConfig(config)
|
||||
derived.IceServers[0].URLs.List[0] = "stun:mutated.test:3478"
|
||||
derived.NatPredictionStunURLs[0] = "stun:mutated.test:3479"
|
||||
if config.STUNURLs[0] != "stun:a.test:3478" || config.NATPredictionSTUNURLs[0] != "stun:a.test:3479" {
|
||||
t.Errorf("IceConfig shares its slices with the Config: %v %v",
|
||||
config.STUNURLs, config.NATPredictionSTUNURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATPredictionStunURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stunURLs []string
|
||||
want []string
|
||||
}{
|
||||
{"no URLs", nil, nil},
|
||||
{"implicit base port", []string{"stun:share.test"},
|
||||
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
|
||||
{"explicit base port", []string{"stun:share.test:3478"},
|
||||
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
|
||||
{"leading zeros in the port", []string{"stun:share.test:03478"},
|
||||
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
|
||||
// WHATWG drops a port equal to the scheme default, so "http://host:80"
|
||||
// reports no port and the TS fallback treats it as 3478.
|
||||
{"HTTP default port reads as the base port", []string{"stun:share.test:80"},
|
||||
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
|
||||
{"uppercase scheme and host", []string{"STUN:Share.TEST:3478"},
|
||||
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
|
||||
{"IPv6 authority", []string{"stun:[2001:DB8::1]:3478"},
|
||||
[]string{"stun:[2001:db8::1]:3479", "stun:[2001:db8::1]:3480"}},
|
||||
{"first base listener wins",
|
||||
[]string{"stun:a.test:5349", "stun:b.test:3478", "stun:c.test:3478"},
|
||||
[]string{"stun:b.test:3479", "stun:b.test:3480"}},
|
||||
{"no base listener", []string{"stun:a.test:5349", "stun:b.test:19302"}, nil},
|
||||
// TS: indexOf(":") is -1, so the whole text becomes the authority.
|
||||
{"no scheme separator", []string{"sharetest"},
|
||||
[]string{"stun:sharetest:3479", "stun:sharetest:3480"}},
|
||||
{"no authority", []string{"stun:"}, nil},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := NATPredictionStunURLs(testCase.stunURLs)
|
||||
if !slices.Equal(got, testCase.want) {
|
||||
t.Errorf("NATPredictionStunURLs = %v, want %v", got, testCase.want)
|
||||
}
|
||||
if len(got) > protocol.MaxNatPredictionAuxiliaryStunURLs {
|
||||
t.Errorf("NATPredictionStunURLs returned %d URLs", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from src/server/local-config.ts.
|
||||
const localRoomLeaseMs = 24 * 60 * 60 * 1_000
|
||||
|
||||
// localPasswordPattern is VISIBLE_ASCII_PATTERN from local-config.ts. Because
|
||||
// it admits one byte per character the TypeScript's extra Buffer.byteLength
|
||||
// bounds are already implied by {8,128}.
|
||||
var localPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`)
|
||||
|
||||
// LocalOptions ports LocalServerConfigOptions. loadLocalServerConfig, the
|
||||
// environment form, went away with the Node child process, so Local never
|
||||
// reads the process environment.
|
||||
type LocalOptions struct {
|
||||
// Port of 0 selects the default 8787, as TypeScript's absent `port` did.
|
||||
// An explicit 0 is indistinguishable from absent here, where TypeScript
|
||||
// rejected it; 0 is not a usable port either way.
|
||||
Port int
|
||||
PublicAddress string
|
||||
PublicOrigin string
|
||||
AllowedAddresses []string
|
||||
SiteAccessPassword string
|
||||
STUNURLs []string
|
||||
NATPredictionSTUNURLs []string
|
||||
}
|
||||
|
||||
// Local ports createLocalServerConfig.
|
||||
func Local(options LocalOptions) (Config, error) {
|
||||
port := options.Port
|
||||
if port == 0 {
|
||||
port = defaultPort
|
||||
}
|
||||
if port < 1 || port > maxPort {
|
||||
return Config{}, fmt.Errorf("Local server port must be an integer between 1 and %d", maxPort)
|
||||
}
|
||||
publicAddress, err := localIPv4(options.PublicAddress, "public address")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if publicAddress == "0.0.0.0" || strings.HasPrefix(publicAddress, "127.") {
|
||||
return Config{}, errors.New("Local server public address must be reachable from the LAN")
|
||||
}
|
||||
siteAccessPassword := strings.TrimFunc(options.SiteAccessPassword, protocol.IsJSWhitespace)
|
||||
if siteAccessPassword != "" && !localPasswordPattern.MatchString(siteAccessPassword) {
|
||||
return Config{}, errors.New("Local access password must contain 8 to 128 visible ASCII bytes")
|
||||
}
|
||||
|
||||
allowedAddresses := []string{publicAddress}
|
||||
for _, address := range options.AllowedAddresses {
|
||||
normalized, err := localIPv4(address, "allowed address")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !slices.Contains(allowedAddresses, normalized) {
|
||||
allowedAddresses = append(allowedAddresses, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
// TS spreads `?? []`, so both lists are always arrays; keeping them non-nil
|
||||
// is what tells IceConfig that NAT prediction URLs were configured.
|
||||
stunURLs := append([]string{}, options.STUNURLs...)
|
||||
natPredictionSTUNURLs := append([]string{}, options.NATPredictionSTUNURLs...)
|
||||
if len(stunURLs)+len(natPredictionSTUNURLs) > protocol.MaxIceServerURLs ||
|
||||
!allValidStunURLs(stunURLs) ||
|
||||
!allValidStunURLs(natPredictionSTUNURLs) ||
|
||||
(len(natPredictionSTUNURLs) != 0 &&
|
||||
len(natPredictionSTUNURLs) != protocol.MaxNatPredictionAuxiliaryStunURLs) ||
|
||||
(len(natPredictionSTUNURLs) > 0 && len(stunURLs) == 0) {
|
||||
return Config{}, errors.New("Local STUN URLs are invalid")
|
||||
}
|
||||
|
||||
// TS builds these with a template string, so a non-default port is always
|
||||
// spelled out and port 80 would produce "http://host:80", which is not the
|
||||
// serialised origin. Kept as-is: Local never runs on 80.
|
||||
origin := func(host string) string { return fmt.Sprintf("http://%s:%d", host, port) }
|
||||
publicBaseURL := &url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", publicAddress, port), Path: "/"}
|
||||
if options.PublicOrigin != "" {
|
||||
publicBaseURL, err = publicHTTPSOrigin(options.PublicOrigin)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
allowedOrigins := map[string]struct{}{
|
||||
origin("localhost"): {},
|
||||
origin("127.0.0.1"): {},
|
||||
}
|
||||
for _, address := range allowedAddresses {
|
||||
allowedOrigins[origin(address)] = struct{}{}
|
||||
}
|
||||
allowedOrigins[Origin(publicBaseURL)] = struct{}{}
|
||||
|
||||
return Config{
|
||||
Env: EnvironmentProduction,
|
||||
Port: port,
|
||||
ListenHost: "0.0.0.0",
|
||||
PublicBaseURL: publicBaseURL,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
SiteAccessPassword: siteAccessPassword,
|
||||
RoomLeaseMs: localRoomLeaseMs,
|
||||
MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit,
|
||||
EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity,
|
||||
STUNURLs: stunURLs,
|
||||
NATPredictionEnabled: len(natPredictionSTUNURLs) == protocol.MaxNatPredictionAuxiliaryStunURLs,
|
||||
NATPredictionSTUNURLs: natPredictionSTUNURLs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func allValidStunURLs(values []string) bool {
|
||||
for _, value := range values {
|
||||
if !protocol.ValidStunURLValue(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// publicHTTPSOrigin ports publicHTTPSOrigin: every failure shares one message.
|
||||
func publicHTTPSOrigin(value string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
|
||||
hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") ||
|
||||
parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Local public origin must be an HTTPS origin")
|
||||
}
|
||||
return &url.URL{Scheme: "https", Host: normalizedHost(parsed), Path: "/"}, nil
|
||||
}
|
||||
|
||||
// localIPv4 ports localIPv4. Node's isIP(value) === 4 accepts only a dotted
|
||||
// quad, so an IPv4-mapped IPv6 literal such as "::ffff:192.168.1.10" — which
|
||||
// net.ParseIP(...).To4() does resolve — is rejected here as well.
|
||||
func localIPv4(value string, name string) (string, error) {
|
||||
address := strings.TrimFunc(value, protocol.IsJSWhitespace)
|
||||
parsed := net.ParseIP(address)
|
||||
if parsed == nil || parsed.To4() == nil || strings.ContainsRune(address, ':') {
|
||||
return "", fmt.Errorf("Local server %s must be an IPv4 address", name)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Ported from tests/local-server-config.test.ts. The environment form,
|
||||
// loadLocalServerConfig, went away with the Node child process, so its scenario
|
||||
// is expressed through LocalOptions here.
|
||||
|
||||
func mustLocal(t *testing.T, options LocalOptions) Config {
|
||||
t.Helper()
|
||||
config, err := Local(options)
|
||||
if err != nil {
|
||||
t.Fatalf("Local: unexpected error %v", err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func TestLocalMapsClientInputs(t *testing.T) {
|
||||
config := mustLocal(t, LocalOptions{
|
||||
Port: 9123,
|
||||
PublicAddress: "192.168.1.10",
|
||||
AllowedAddresses: []string{"10.0.0.8", "192.168.1.10"},
|
||||
SiteAccessPassword: "local-access-password",
|
||||
})
|
||||
|
||||
if config.Env != EnvironmentProduction {
|
||||
t.Errorf("Env = %q", config.Env)
|
||||
}
|
||||
if config.Port != 9123 {
|
||||
t.Errorf("Port = %d", config.Port)
|
||||
}
|
||||
if config.ListenHost != "0.0.0.0" {
|
||||
t.Errorf("ListenHost = %q", config.ListenHost)
|
||||
}
|
||||
if config.SiteAccessPassword != "local-access-password" {
|
||||
t.Errorf("SiteAccessPassword = %q", config.SiteAccessPassword)
|
||||
}
|
||||
if config.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
|
||||
}
|
||||
if config.MaxViewersPerRoom != 20 {
|
||||
t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom)
|
||||
}
|
||||
if config.EndpointMediaCopyCapacity != 2 {
|
||||
t.Errorf("EndpointMediaCopyCapacity = %d", config.EndpointMediaCopyCapacity)
|
||||
}
|
||||
if len(config.STUNURLs) != 0 {
|
||||
t.Errorf("STUNURLs = %v", config.STUNURLs)
|
||||
}
|
||||
if config.NATPredictionEnabled {
|
||||
t.Error("NATPredictionEnabled = true")
|
||||
}
|
||||
if got := config.PublicBaseURL.String(); got != "http://192.168.1.10:9123/" {
|
||||
t.Errorf("PublicBaseURL = %q", got)
|
||||
}
|
||||
want := map[string]struct{}{
|
||||
"http://localhost:9123": {},
|
||||
"http://127.0.0.1:9123": {},
|
||||
"http://192.168.1.10:9123": {},
|
||||
"http://10.0.0.8:9123": {},
|
||||
}
|
||||
if !reflect.DeepEqual(config.AllowedOrigins, want) {
|
||||
t.Errorf("AllowedOrigins = %v, want %v", config.AllowedOrigins, want)
|
||||
}
|
||||
if config.LiveKit != nil {
|
||||
t.Errorf("LiveKit = %+v", config.LiveKit)
|
||||
}
|
||||
if config.RoomDatabasePath != "" {
|
||||
t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTunnelledClient(t *testing.T) {
|
||||
config := mustLocal(t, LocalOptions{
|
||||
Port: 9234,
|
||||
PublicAddress: "192.168.50.4",
|
||||
AllowedAddresses: []string{"192.168.50.4", "10.10.0.4"},
|
||||
SiteAccessPassword: "persistent-local-password",
|
||||
PublicOrigin: "https://small-bright-room.trycloudflare.com",
|
||||
STUNURLs: []string{"stun:stun.example:3478", "stun:stun.example:3479"},
|
||||
NATPredictionSTUNURLs: []string{"stun:survey-a.example:3478", "stun:survey-b.example:3478"},
|
||||
})
|
||||
|
||||
if config.Port != 9234 {
|
||||
t.Errorf("Port = %d", config.Port)
|
||||
}
|
||||
if got := Origin(config.PublicBaseURL); got != "https://small-bright-room.trycloudflare.com" {
|
||||
t.Errorf("public origin = %q", got)
|
||||
}
|
||||
for _, origin := range []string{
|
||||
"https://small-bright-room.trycloudflare.com", "http://10.10.0.4:9234",
|
||||
} {
|
||||
if _, ok := config.AllowedOrigins[origin]; !ok {
|
||||
t.Errorf("AllowedOrigins is missing %q: %v", origin, config.AllowedOrigins)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(config.STUNURLs, []string{"stun:stun.example:3478", "stun:stun.example:3479"}) {
|
||||
t.Errorf("STUNURLs = %v", config.STUNURLs)
|
||||
}
|
||||
if !config.NATPredictionEnabled {
|
||||
t.Error("NATPredictionEnabled = false")
|
||||
}
|
||||
if !slices.Equal(config.NATPredictionSTUNURLs,
|
||||
[]string{"stun:survey-a.example:3478", "stun:survey-b.example:3478"}) {
|
||||
t.Errorf("NATPredictionSTUNURLs = %v", config.NATPredictionSTUNURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDefaults(t *testing.T) {
|
||||
config := mustLocal(t, LocalOptions{PublicAddress: "192.168.1.10"})
|
||||
|
||||
if config.SiteAccessPassword != "" {
|
||||
t.Errorf("SiteAccessPassword = %q, want the open site", config.SiteAccessPassword)
|
||||
}
|
||||
if config.Port != 8787 {
|
||||
t.Errorf("Port = %d, want the 8787 default", config.Port)
|
||||
}
|
||||
if got := Origin(config.PublicBaseURL); got != "http://192.168.1.10:8787" {
|
||||
t.Errorf("public origin = %q", got)
|
||||
}
|
||||
// A configured (empty) prediction list stays non-nil so IceConfig does not
|
||||
// derive one, which is TS's `?? []` spread.
|
||||
if config.NATPredictionSTUNURLs == nil {
|
||||
t.Error("NATPredictionSTUNURLs = nil, want an empty configured list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
options LocalOptions
|
||||
want string
|
||||
}{
|
||||
{"hostname public address", LocalOptions{PublicAddress: "localhost",
|
||||
SiteAccessPassword: "valid-password"},
|
||||
"Local server public address must be an IPv4 address"},
|
||||
{"IPv6 public address", LocalOptions{PublicAddress: "::ffff:192.168.1.10"},
|
||||
"Local server public address must be an IPv4 address"},
|
||||
{"loopback public address", LocalOptions{PublicAddress: "127.0.0.1",
|
||||
SiteAccessPassword: "valid-password"},
|
||||
"Local server public address must be reachable from the LAN"},
|
||||
{"unspecified public address", LocalOptions{PublicAddress: "0.0.0.0",
|
||||
SiteAccessPassword: "valid-password"},
|
||||
"Local server public address must be reachable from the LAN"},
|
||||
{"short password", LocalOptions{PublicAddress: "192.168.1.2", SiteAccessPassword: "short"},
|
||||
"Local access password must contain 8 to 128 visible ASCII bytes"},
|
||||
{"password outside ASCII", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
SiteAccessPassword: "密码密码密码密码"},
|
||||
"Local access password must contain 8 to 128 visible ASCII bytes"},
|
||||
{"long password", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
SiteAccessPassword: strings.Repeat("x", 129)},
|
||||
"Local access password must contain 8 to 128 visible ASCII bytes"},
|
||||
{"port above the TCP ceiling", LocalOptions{Port: 70_000, PublicAddress: "192.168.1.2"},
|
||||
"Local server port must be an integer between 1 and 65535"},
|
||||
{"negative port", LocalOptions{Port: -1, PublicAddress: "192.168.1.2"},
|
||||
"Local server port must be an integer between 1 and 65535"},
|
||||
{"hostname allowed address", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
AllowedAddresses: []string{"share.local"}},
|
||||
"Local server allowed address must be an IPv4 address"},
|
||||
{"plaintext public origin", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
PublicOrigin: "http://public.example", SiteAccessPassword: "valid-password"},
|
||||
"Local public origin must be an HTTPS origin"},
|
||||
{"public origin with a path", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
PublicOrigin: "https://public.example/path", SiteAccessPassword: "valid-password"},
|
||||
"Local public origin must be an HTTPS origin"},
|
||||
{"public origin with userinfo", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
PublicOrigin: "https://user@public.example", SiteAccessPassword: "valid-password"},
|
||||
"Local public origin must be an HTTPS origin"},
|
||||
{"unparsable public origin", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
PublicOrigin: "public.example"},
|
||||
"Local public origin must be an HTTPS origin"},
|
||||
{"invalid STUN URL", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
STUNURLs: []string{"turn:turn.test:3478"}},
|
||||
"Local STUN URLs are invalid"},
|
||||
{"invalid prediction STUN URL", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
STUNURLs: []string{"stun:a.test:3478"},
|
||||
NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stuns:b.test:3480"}},
|
||||
"Local STUN URLs are invalid"},
|
||||
{"single prediction STUN URL", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
STUNURLs: []string{"stun:a.test:3478"}, NATPredictionSTUNURLs: []string{"stun:a.test:3479"}},
|
||||
"Local STUN URLs are invalid"},
|
||||
{"prediction without ordinary STUN", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"}},
|
||||
"Local STUN URLs are invalid"},
|
||||
{"more ICE URLs than the wire carries", LocalOptions{PublicAddress: "192.168.1.2",
|
||||
STUNURLs: []string{
|
||||
"stun:a.test:3478", "stun:b.test:3478", "stun:c.test:3478", "stun:d.test:3478",
|
||||
"stun:e.test:3478", "stun:f.test:3478", "stun:g.test:3478",
|
||||
},
|
||||
NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"}},
|
||||
"Local STUN URLs are invalid"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := Local(testCase.options)
|
||||
if err == nil {
|
||||
t.Fatalf("Local: want error containing %q", testCase.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Errorf("Local: error %q, want it to contain %q", err, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package ordered provides an insertion-ordered map with the iteration
|
||||
// semantics of a JavaScript Map. The TypeScript server relied on those
|
||||
// semantics wherever it iterated a Map or Set: wire-visible participant order,
|
||||
// candidate ordering, round-robin state, and delete-during-iteration loops.
|
||||
package ordered
|
||||
|
||||
import "iter"
|
||||
|
||||
type entry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
deleted bool
|
||||
}
|
||||
|
||||
// Map keeps keys in first-insertion order. Set on an existing key keeps its
|
||||
// position; MoveToBack reproduces the JavaScript delete-then-set idiom.
|
||||
// Iteration is live: an entry deleted before it is visited is skipped and an
|
||||
// entry appended during iteration is visited, exactly like a JavaScript Map.
|
||||
// The zero value is ready to use.
|
||||
type Map[K comparable, V any] struct {
|
||||
entries []entry[K, V]
|
||||
index map[K]int
|
||||
deleted int
|
||||
iterating int
|
||||
}
|
||||
|
||||
// Len reports the number of live entries.
|
||||
func (m *Map[K, V]) Len() int { return len(m.index) }
|
||||
|
||||
// Get returns the value for key and whether it is present.
|
||||
func (m *Map[K, V]) Get(key K) (V, bool) {
|
||||
if position, ok := m.index[key]; ok {
|
||||
return m.entries[position].value, true
|
||||
}
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Has reports whether key is present.
|
||||
func (m *Map[K, V]) Has(key K) bool {
|
||||
_, ok := m.index[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Set stores value under key, keeping the existing position when the key is
|
||||
// already present and appending otherwise.
|
||||
func (m *Map[K, V]) Set(key K, value V) {
|
||||
if position, ok := m.index[key]; ok {
|
||||
m.entries[position].value = value
|
||||
return
|
||||
}
|
||||
if m.index == nil {
|
||||
m.index = make(map[K]int)
|
||||
}
|
||||
m.index[key] = len(m.entries)
|
||||
m.entries = append(m.entries, entry[K, V]{key: key, value: value})
|
||||
}
|
||||
|
||||
// MoveToBack re-inserts key at the end, as JavaScript `map.delete(k);
|
||||
// map.set(k, v)` does. It is a plain Set when the key is absent.
|
||||
func (m *Map[K, V]) MoveToBack(key K, value V) {
|
||||
m.Delete(key)
|
||||
m.Set(key, value)
|
||||
}
|
||||
|
||||
// Delete removes key and reports whether it was present.
|
||||
func (m *Map[K, V]) Delete(key K) bool {
|
||||
position, ok := m.index[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(m.index, key)
|
||||
var zero V
|
||||
m.entries[position].value = zero
|
||||
m.entries[position].deleted = true
|
||||
m.deleted++
|
||||
m.compact()
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear removes every entry. Dropping the index before compacting is safe
|
||||
// because every entry is now a tombstone, so compact writes no index entry.
|
||||
func (m *Map[K, V]) Clear() {
|
||||
for position := range m.entries {
|
||||
if !m.entries[position].deleted {
|
||||
var zero V
|
||||
m.entries[position].value = zero
|
||||
m.entries[position].deleted = true
|
||||
m.deleted++
|
||||
}
|
||||
}
|
||||
m.index = nil
|
||||
m.compact()
|
||||
}
|
||||
|
||||
// Keys returns a snapshot of the live keys in order.
|
||||
func (m *Map[K, V]) Keys() []K {
|
||||
keys := make([]K, 0, len(m.index))
|
||||
for _, current := range m.entries {
|
||||
if !current.deleted {
|
||||
keys = append(keys, current.key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Values returns a snapshot of the live values in order.
|
||||
func (m *Map[K, V]) Values() []V {
|
||||
values := make([]V, 0, len(m.index))
|
||||
for _, current := range m.entries {
|
||||
if !current.deleted {
|
||||
values = append(values, current.value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// All iterates live entries in order with JavaScript Map semantics. Mutating
|
||||
// the map inside the loop is allowed.
|
||||
func (m *Map[K, V]) All() iter.Seq2[K, V] {
|
||||
return func(yield func(K, V) bool) {
|
||||
m.iterating++
|
||||
defer func() {
|
||||
m.iterating--
|
||||
m.compact()
|
||||
}()
|
||||
for position := 0; position < len(m.entries); position++ {
|
||||
current := m.entries[position]
|
||||
if current.deleted {
|
||||
continue
|
||||
}
|
||||
if !yield(current.key, current.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compact drops tombstones once they dominate, but never while an iteration
|
||||
// is walking the entry slice.
|
||||
func (m *Map[K, V]) compact() {
|
||||
if m.iterating > 0 || m.deleted == 0 || m.deleted*2 < len(m.entries) {
|
||||
return
|
||||
}
|
||||
live := make([]entry[K, V], 0, len(m.index))
|
||||
for _, current := range m.entries {
|
||||
if !current.deleted {
|
||||
m.index[current.key] = len(live)
|
||||
live = append(live, current)
|
||||
}
|
||||
}
|
||||
m.entries = live
|
||||
m.deleted = 0
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ordered
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMapKeepsInsertionOrderAndPositionOnSet(t *testing.T) {
|
||||
var m Map[string, int]
|
||||
m.Set("a", 1)
|
||||
m.Set("b", 2)
|
||||
m.Set("c", 3)
|
||||
m.Set("a", 10)
|
||||
if got := m.Keys(); !slices.Equal(got, []string{"a", "b", "c"}) {
|
||||
t.Fatalf("keys = %v", got)
|
||||
}
|
||||
if got := m.Values(); !slices.Equal(got, []int{10, 2, 3}) {
|
||||
t.Fatalf("values = %v", got)
|
||||
}
|
||||
m.MoveToBack("a", 11)
|
||||
if got := m.Keys(); !slices.Equal(got, []string{"b", "c", "a"}) {
|
||||
t.Fatalf("keys after MoveToBack = %v", got)
|
||||
}
|
||||
if value, ok := m.Get("a"); !ok || value != 11 || m.Len() != 3 {
|
||||
t.Fatalf("get a = %v %v len %d", value, ok, m.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapLiveIterationSkipsDeletedAndVisitsAppended(t *testing.T) {
|
||||
var m Map[string, int]
|
||||
for _, key := range []string{"a", "b", "c", "d"} {
|
||||
m.Set(key, len(key))
|
||||
}
|
||||
var visited []string
|
||||
for key := range m.All() {
|
||||
visited = append(visited, key)
|
||||
switch key {
|
||||
case "a":
|
||||
m.Delete("c") // deleted before its turn: skipped, like a JS Map
|
||||
case "b":
|
||||
m.Set("e", 1) // appended during iteration: visited
|
||||
}
|
||||
}
|
||||
if !slices.Equal(visited, []string{"a", "b", "d", "e"}) {
|
||||
t.Fatalf("visited = %v", visited)
|
||||
}
|
||||
if got := m.Keys(); !slices.Equal(got, []string{"a", "b", "d", "e"}) {
|
||||
t.Fatalf("keys = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapCompactionPreservesOrderAndIndex(t *testing.T) {
|
||||
var m Map[int, int]
|
||||
for index := 0; index < 100; index++ {
|
||||
m.Set(index, index)
|
||||
}
|
||||
for index := 0; index < 100; index += 2 {
|
||||
m.Delete(index)
|
||||
}
|
||||
if m.Len() != 50 || len(m.entries) > 60 {
|
||||
t.Fatalf("len %d entries %d", m.Len(), len(m.entries))
|
||||
}
|
||||
for index := 1; index < 100; index += 2 {
|
||||
if value, ok := m.Get(index); !ok || value != index {
|
||||
t.Fatalf("get %d = %v %v", index, value, ok)
|
||||
}
|
||||
}
|
||||
keys := m.Keys()
|
||||
if keys[0] != 1 || keys[49] != 99 || !slices.IsSorted(keys) {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
if m.Delete(1000) {
|
||||
t.Fatal("deleting an absent key must report false")
|
||||
}
|
||||
m.Clear()
|
||||
if m.Len() != 0 || len(m.Keys()) != 0 {
|
||||
t.Fatal("clear must empty the map")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ClientMessage is one member of clientMessageSchema. The interface is sealed:
|
||||
// only the message structs in this file implement it.
|
||||
type ClientMessage interface {
|
||||
isClientMessage()
|
||||
}
|
||||
|
||||
// AuthenticateHostMessage is the host member of authenticateMessageSchema.
|
||||
type AuthenticateHostMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
RoomID string `json:"roomId"`
|
||||
Role Role `json:"role"`
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"clientId"`
|
||||
ShareGeneration string `json:"shareGeneration,omitempty"`
|
||||
SharingPaused *bool `json:"sharingPaused,omitempty"`
|
||||
QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
|
||||
RoutePolicy RoutePolicy `json:"routePolicy"`
|
||||
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
||||
DisplayName *DisplayName `json:"displayName,omitempty"`
|
||||
}
|
||||
|
||||
// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema.
|
||||
type AuthenticateViewerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
RoomID string `json:"roomId"`
|
||||
Role Role `json:"role"`
|
||||
ClientID string `json:"clientId"`
|
||||
ViewerGrant string `json:"viewerGrant,omitempty"`
|
||||
ViewerPassword string `json:"viewerPassword,omitempty"`
|
||||
DisplayName *DisplayName `json:"displayName,omitempty"`
|
||||
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
||||
}
|
||||
|
||||
// SignalingChallengeMessage is { type: "signaling-challenge", sequence }.
|
||||
type SignalingChallengeMessage struct {
|
||||
Type string `json:"type"`
|
||||
Sequence Int `json:"sequence"`
|
||||
}
|
||||
|
||||
// ClientSignalMessage is the client "signal" message.
|
||||
type ClientSignalMessage struct {
|
||||
Type string `json:"type"`
|
||||
TargetPeerID string `json:"targetPeerId,omitempty"`
|
||||
Payload SignalPayload `json:"payload"`
|
||||
}
|
||||
|
||||
// ClientRestartRequestMessage is the client "restart-request" message.
|
||||
type ClientRestartRequestMessage struct {
|
||||
Type string `json:"type"`
|
||||
TargetPeerID string `json:"targetPeerId,omitempty"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Rebuild bool `json:"rebuild"`
|
||||
}
|
||||
|
||||
// SetQualitySettingsMessage is { type: "set-quality-settings", qualitySettings }.
|
||||
type SetQualitySettingsMessage struct {
|
||||
Type string `json:"type"`
|
||||
QualitySettings QualitySettings `json:"qualitySettings"`
|
||||
}
|
||||
|
||||
// RelayCapacityMessage is { type: "relay-capacity", downstreamEdges }.
|
||||
type RelayCapacityMessage struct {
|
||||
Type string `json:"type"`
|
||||
DownstreamEdges Int `json:"downstreamEdges"`
|
||||
}
|
||||
|
||||
// RouteReadyMessage is { type: "route-ready", revision, phase, qualityApproved? }.
|
||||
type RouteReadyMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
Phase string `json:"phase"`
|
||||
QualityApproved bool `json:"qualityApproved,omitempty"`
|
||||
}
|
||||
|
||||
// RouteTransportConnectedMessage is the "route-transport-connected" message.
|
||||
type RouteTransportConnectedMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// RouteMediaUnavailableMessage is the "route-media-unavailable" message.
|
||||
type RouteMediaUnavailableMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
}
|
||||
|
||||
// RouteFailedMessage is { type: "route-failed", revision, phase, connectionId }.
|
||||
type RouteFailedMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
Phase string `json:"phase"`
|
||||
ConnectionID *string `json:"connectionId"`
|
||||
}
|
||||
|
||||
// RefreshSfuMessage is { type: "refresh-sfu", revision }.
|
||||
type RefreshSfuMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
}
|
||||
|
||||
// RequestRouteDiagnosticMessage is { type: "request-route-diagnostic" }.
|
||||
type RequestRouteDiagnosticMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ViewerQualityEvidenceMessage is viewerQualityEvidenceMessageSchema.
|
||||
type ViewerQualityEvidenceMessage struct {
|
||||
Type string `json:"type"`
|
||||
Guard ViewerQualityEvidenceGuard `json:"guard"`
|
||||
Sequence Int `json:"sequence"`
|
||||
WindowMs Int `json:"windowMs"`
|
||||
Metrics ViewerQualityEvidenceMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
// SenderQualityEvidenceMessage is senderQualityEvidenceMessageSchema.
|
||||
type SenderQualityEvidenceMessage struct {
|
||||
Type string `json:"type"`
|
||||
ChildPeerID string `json:"childPeerId"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
RtpStatsID *string `json:"rtpStatsId"`
|
||||
TrackIdentifier *string `json:"trackIdentifier"`
|
||||
SampleTimestampMs *Num `json:"sampleTimestampMs"`
|
||||
RouteRevision Int `json:"routeRevision"`
|
||||
State string `json:"state"`
|
||||
Diagnostics SenderQualityDiagnostics `json:"diagnostics"`
|
||||
}
|
||||
|
||||
// SfuPublisherQualityEvidenceMessage is
|
||||
// sfuPublisherQualityEvidenceMessageSchema.
|
||||
type SfuPublisherQualityEvidenceMessage struct {
|
||||
Type string `json:"type"`
|
||||
PublicationGeneration string `json:"publicationGeneration"`
|
||||
RouteRevision Int `json:"routeRevision"`
|
||||
State string `json:"state"`
|
||||
SampleTimestampMs *Num `json:"sampleTimestampMs"`
|
||||
Diagnostics SenderQualityDiagnostics `json:"diagnostics"`
|
||||
}
|
||||
|
||||
// ResetSenderQualityMessage is { type: "reset-sender-quality" }.
|
||||
type ResetSenderQualityMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SetDisplayNameMessage is { type: "set-display-name", displayName }.
|
||||
type SetDisplayNameMessage struct {
|
||||
Type string `json:"type"`
|
||||
DisplayName DisplayName `json:"displayName"`
|
||||
}
|
||||
|
||||
// SetSharingPausedMessage is { type: "set-sharing-paused", shareGeneration, paused }.
|
||||
type SetSharingPausedMessage struct {
|
||||
Type string `json:"type"`
|
||||
ShareGeneration string `json:"shareGeneration"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
// StopSharingMessage is { type: "stop-sharing", shareGeneration? }.
|
||||
type StopSharingMessage struct {
|
||||
Type string `json:"type"`
|
||||
ShareGeneration string `json:"shareGeneration,omitempty"`
|
||||
}
|
||||
|
||||
// AbandonRoomMessage is { type: "abandon-room" }.
|
||||
type AbandonRoomMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (AuthenticateHostMessage) isClientMessage() {}
|
||||
func (AuthenticateViewerMessage) isClientMessage() {}
|
||||
func (SignalingChallengeMessage) isClientMessage() {}
|
||||
func (ClientSignalMessage) isClientMessage() {}
|
||||
func (ClientRestartRequestMessage) isClientMessage() {}
|
||||
func (SetQualitySettingsMessage) isClientMessage() {}
|
||||
func (RelayCapacityMessage) isClientMessage() {}
|
||||
func (RouteReadyMessage) isClientMessage() {}
|
||||
func (RouteTransportConnectedMessage) isClientMessage() {}
|
||||
func (RouteMediaUnavailableMessage) isClientMessage() {}
|
||||
func (RouteFailedMessage) isClientMessage() {}
|
||||
func (RefreshSfuMessage) isClientMessage() {}
|
||||
func (RequestRouteDiagnosticMessage) isClientMessage() {}
|
||||
func (ViewerQualityEvidenceMessage) isClientMessage() {}
|
||||
func (SenderQualityEvidenceMessage) isClientMessage() {}
|
||||
func (SfuPublisherQualityEvidenceMessage) isClientMessage() {}
|
||||
func (ResetSenderQualityMessage) isClientMessage() {}
|
||||
func (SetDisplayNameMessage) isClientMessage() {}
|
||||
func (SetSharingPausedMessage) isClientMessage() {}
|
||||
func (StopSharingMessage) isClientMessage() {}
|
||||
func (AbandonRoomMessage) isClientMessage() {}
|
||||
|
||||
// DecodeClientMessage ports decodeClientMessage: JSON.parse followed by
|
||||
// clientMessageSchema.parse. Every member of the union carries a distinct
|
||||
// `type` literal, so the union is resolved by that key.
|
||||
func DecodeClientMessage(data []byte) (ClientMessage, error) {
|
||||
messageType, err := typeOf(data, "type")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch messageType {
|
||||
case "authenticate":
|
||||
return decodeAuthenticate(data)
|
||||
case "signaling-challenge":
|
||||
return decodeSignalingChallenge(data)
|
||||
case "signal":
|
||||
return decodeClientSignal(data)
|
||||
case "restart-request":
|
||||
return decodeClientRestartRequest(data)
|
||||
case "set-quality-settings":
|
||||
return decodeSetQualitySettings(data)
|
||||
case "relay-capacity":
|
||||
return decodeRelayCapacity(data)
|
||||
case "route-ready":
|
||||
return decodeRouteReady(data)
|
||||
case "route-transport-connected":
|
||||
return decodeRouteTransportConnected(data)
|
||||
case "route-media-unavailable":
|
||||
return decodeRouteMediaUnavailable(data)
|
||||
case "route-failed":
|
||||
return decodeRouteFailed(data)
|
||||
case "refresh-sfu":
|
||||
return decodeRefreshSfu(data)
|
||||
case "request-route-diagnostic":
|
||||
return decodeEmptyClientMessage(data, RequestRouteDiagnosticMessage{Type: messageType})
|
||||
case "viewer-quality-evidence":
|
||||
return decodeViewerQualityEvidence(data)
|
||||
case "sender-quality-evidence":
|
||||
return decodeSenderQualityEvidence(data)
|
||||
case "sfu-publisher-quality-evidence":
|
||||
return decodeSfuPublisherQualityEvidence(data)
|
||||
case "reset-sender-quality":
|
||||
return decodeEmptyClientMessage(data, ResetSenderQualityMessage{Type: messageType})
|
||||
case "set-display-name":
|
||||
return decodeSetDisplayName(data)
|
||||
case "set-sharing-paused":
|
||||
return decodeSetSharingPaused(data)
|
||||
case "stop-sharing":
|
||||
return decodeStopSharing(data)
|
||||
case "abandon-room":
|
||||
return decodeEmptyClientMessage(data, AbandonRoomMessage{Type: messageType})
|
||||
}
|
||||
return nil, fmt.Errorf("unknown client message type %q", messageType)
|
||||
}
|
||||
|
||||
func decodeEmptyClientMessage(data []byte, message ClientMessage) (ClientMessage, error) {
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
present, err := decodeObject(data, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeAuthenticate(data []byte) (ClientMessage, error) {
|
||||
role, err := typeOf(data, "role")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch role {
|
||||
case RoleHost:
|
||||
var message AuthenticateHostMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "protocol", "roomId", "role", "token", "clientId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("shareGeneration", "sharingPaused", "qualitySettings",
|
||||
"routePolicy", "viewerPresence", "displayName"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Protocol != SignalingProtocol {
|
||||
return nil, errors.New("protocol is not the current signaling generation")
|
||||
}
|
||||
if !ValidRoomCode(message.RoomID) {
|
||||
return nil, errors.New("roomId is not a room code")
|
||||
}
|
||||
if !ValidToken(message.Token) {
|
||||
return nil, errors.New("token is malformed")
|
||||
}
|
||||
if !ValidOpaqueID(message.ClientID) {
|
||||
return nil, errors.New("clientId is not an opaque id")
|
||||
}
|
||||
if present.has("shareGeneration") && !ValidOpaqueID(message.ShareGeneration) {
|
||||
return nil, errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
if present.has("viewerPresence") && !message.ViewerPresence {
|
||||
return nil, errors.New("viewerPresence must be true when present")
|
||||
}
|
||||
if !present.has("routePolicy") {
|
||||
message.RoutePolicy = DefaultRoutePolicy
|
||||
}
|
||||
return message, nil
|
||||
case RoleViewer:
|
||||
var message AuthenticateViewerMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "protocol", "roomId", "role", "clientId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional(
|
||||
"viewerGrant", "viewerPassword", "displayName", "viewerPresence"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Protocol != SignalingProtocol {
|
||||
return nil, errors.New("protocol is not the current signaling generation")
|
||||
}
|
||||
if !ValidRoomCode(message.RoomID) {
|
||||
return nil, errors.New("roomId is not a room code")
|
||||
}
|
||||
if !ValidOpaqueID(message.ClientID) {
|
||||
return nil, errors.New("clientId is not an opaque id")
|
||||
}
|
||||
if present.has("viewerGrant") && !ValidViewerGrant(message.ViewerGrant) {
|
||||
return nil, errors.New("viewerGrant is malformed")
|
||||
}
|
||||
if present.has("viewerPassword") && !ValidViewerPassword(message.ViewerPassword) {
|
||||
return nil, errors.New("viewerPassword is malformed")
|
||||
}
|
||||
if present.has("viewerPresence") && !message.ViewerPresence {
|
||||
return nil, errors.New("viewerPresence must be true when present")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown authenticate role %q", role)
|
||||
}
|
||||
|
||||
func decodeSignalingChallenge(data []byte) (ClientMessage, error) {
|
||||
var message SignalingChallengeMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "sequence"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inRangeInt(message.Sequence, 0, MaxSafeInteger) {
|
||||
return nil, errors.New("sequence is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeClientSignal(data []byte) (ClientMessage, error) {
|
||||
var message ClientSignalMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "payload"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("targetPeerId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if present.has("targetPeerId") && !ValidOpaqueID(message.TargetPeerID) {
|
||||
return nil, errors.New("targetPeerId is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeClientRestartRequest(data []byte) (ClientMessage, error) {
|
||||
var message ClientRestartRequestMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "connectionId", "rebuild"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("targetPeerId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if present.has("targetPeerId") && !ValidOpaqueID(message.TargetPeerID) {
|
||||
return nil, errors.New("targetPeerId is not an opaque id")
|
||||
}
|
||||
if !ValidOpaqueID(message.ConnectionID) {
|
||||
return nil, errors.New("connectionId is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeSetQualitySettings(data []byte) (ClientMessage, error) {
|
||||
var message SetQualitySettingsMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "qualitySettings"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRelayCapacity(data []byte) (ClientMessage, error) {
|
||||
var message RelayCapacityMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "downstreamEdges"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inRangeInt(message.DownstreamEdges, 0, MaxEndpointMediaCopyCapacity) {
|
||||
return nil, errors.New("downstreamEdges is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteReady(data []byte) (ClientMessage, error) {
|
||||
var message RouteReadyMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "phase"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("qualityApproved"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
if !enumOf(message.Phase, mediaRoutePhases...) {
|
||||
return nil, fmt.Errorf("unknown phase %q", message.Phase)
|
||||
}
|
||||
if present.has("qualityApproved") && !message.QualityApproved {
|
||||
return nil, errors.New("qualityApproved must be true when present")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteTransportConnected(data []byte) (ClientMessage, error) {
|
||||
var message RouteTransportConnectedMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "connectionId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
if !ValidOpaqueID(message.ConnectionID) {
|
||||
return nil, errors.New("connectionId is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteMediaUnavailable(data []byte) (ClientMessage, error) {
|
||||
var message RouteMediaUnavailableMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteFailed(data []byte) (ClientMessage, error) {
|
||||
var message RouteFailedMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "phase"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.requireNullable("connectionId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
if !enumOf(message.Phase, mediaRoutePhases...) {
|
||||
return nil, fmt.Errorf("unknown phase %q", message.Phase)
|
||||
}
|
||||
if message.ConnectionID != nil && !ValidOpaqueID(*message.ConnectionID) {
|
||||
return nil, errors.New("connectionId is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRefreshSfu(data []byte) (ClientMessage, error) {
|
||||
var message RefreshSfuMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeViewerQualityEvidence(data []byte) (ClientMessage, error) {
|
||||
var message ViewerQualityEvidenceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "guard", "sequence", "windowMs", "metrics"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateEvidenceWindow(message.Sequence, message.WindowMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func validateEvidenceWindow(sequence, windowMs Int) error {
|
||||
if !inRangeInt(sequence, 0, MaxSafeInteger) {
|
||||
return errors.New("sequence is out of range")
|
||||
}
|
||||
if !inRangeInt(windowMs, 1_000, 5_000) {
|
||||
return errors.New("windowMs is out of range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeSenderQualityEvidence(data []byte) (ClientMessage, error) {
|
||||
var message SenderQualityEvidenceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "childPeerId", "connectionId", "routeRevision",
|
||||
"state", "diagnostics"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.requireNullable(
|
||||
"rtpStatsId", "trackIdentifier", "sampleTimestampMs"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ChildPeerID) || !ValidOpaqueID(message.ConnectionID) {
|
||||
return nil, errors.New("sender quality evidence ids are not opaque ids")
|
||||
}
|
||||
if err := boundedIdentity(message.RtpStatsID, "rtpStatsId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := boundedIdentity(message.TrackIdentifier, "trackIdentifier"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.SampleTimestampMs != nil &&
|
||||
!inRangeNum(*message.SampleTimestampMs, 0, MaxSafeInteger) {
|
||||
return nil, errors.New("sampleTimestampMs is out of range")
|
||||
}
|
||||
if !validRevision(message.RouteRevision) {
|
||||
return nil, errors.New("routeRevision is out of range")
|
||||
}
|
||||
if !enumOf(message.State, senderQualityStates...) {
|
||||
return nil, fmt.Errorf("unknown sender quality state %q", message.State)
|
||||
}
|
||||
// superRefine: known sender quality needs exact RTP identity.
|
||||
if message.State != "unknown" &&
|
||||
(message.RtpStatsID == nil || message.TrackIdentifier == nil ||
|
||||
message.SampleTimestampMs == nil) {
|
||||
return nil, errors.New("Known sender quality needs exact RTP identity")
|
||||
}
|
||||
if err := senderQualityReasonMatchesState(
|
||||
message.State, message.Diagnostics.Reason,
|
||||
"Sender quality reason must match its native state"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeSfuPublisherQualityEvidence(data []byte) (ClientMessage, error) {
|
||||
var message SfuPublisherQualityEvidenceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require(
|
||||
"type", "publicationGeneration", "routeRevision", "state", "diagnostics"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.requireNullable("sampleTimestampMs"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.PublicationGeneration) {
|
||||
return nil, errors.New("publicationGeneration is not an opaque id")
|
||||
}
|
||||
if !validRevision(message.RouteRevision) {
|
||||
return nil, errors.New("routeRevision is out of range")
|
||||
}
|
||||
if !enumOf(message.State, senderQualityStates...) {
|
||||
return nil, fmt.Errorf("unknown sfu publisher quality state %q", message.State)
|
||||
}
|
||||
if message.SampleTimestampMs != nil &&
|
||||
!inRangeNum(*message.SampleTimestampMs, 0, MaxSafeInteger) {
|
||||
return nil, errors.New("sampleTimestampMs is out of range")
|
||||
}
|
||||
// superRefine: known SFU publisher quality needs an exact sample.
|
||||
if message.State != "unknown" && message.SampleTimestampMs == nil {
|
||||
return nil, errors.New("Known SFU publisher quality needs an exact sample")
|
||||
}
|
||||
if err := senderQualityReasonMatchesState(
|
||||
message.State, message.Diagnostics.Reason,
|
||||
"SFU publisher reason must match its native state"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func senderQualityReasonMatchesState(state string, reason *string, message string) error {
|
||||
value := ""
|
||||
if reason != nil {
|
||||
value = *reason
|
||||
}
|
||||
invalid := (state == "healthy" && value != "none") ||
|
||||
(state == "degraded" && value != "bandwidth" && value != "cpu") ||
|
||||
(state == "unknown" && reason != nil)
|
||||
if invalid {
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boundedIdentity(value *string, key string) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if length := UTF16Length(*value); length < 1 || length > 256 {
|
||||
return fmt.Errorf("%s length is out of range", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeSetDisplayName(data []byte) (ClientMessage, error) {
|
||||
var message SetDisplayNameMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "displayName"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeSetSharingPaused(data []byte) (ClientMessage, error) {
|
||||
var message SetSharingPausedMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "shareGeneration", "paused"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ShareGeneration) {
|
||||
return nil, errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeStopSharing(data []byte) (ClientMessage, error) {
|
||||
var message StopSharingMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("shareGeneration"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if present.has("shareGeneration") && !ValidOpaqueID(message.ShareGeneration) {
|
||||
return nil, errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// fields holds the raw members of one JSON object. Validation reads it to tell
|
||||
// "absent" from "null" the way zod's .optional() / .nullable() pair does, which
|
||||
// encoding/json alone cannot express. Duplicate keys collapse to the last
|
||||
// occurrence, matching JSON.parse.
|
||||
type fields map[string]json.RawMessage
|
||||
|
||||
// decodeObject decodes one strict (zod `.strict()`) object: unknown keys are
|
||||
// rejected and the raw member map is returned for presence checks.
|
||||
func decodeObject(data []byte, target any) (fields, error) {
|
||||
var raw fields
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// JSON.parse keeps only the last occurrence of a duplicate key, so the
|
||||
// earlier ones must never reach the strict decode. Re-encoding the member
|
||||
// map drops them.
|
||||
// ponytail: one extra marshal per object; skip it behind a duplicate-key
|
||||
// scan if signaling ever shows up in a profile.
|
||||
canonical, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(canonical))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// marshalJSON encodes without HTML escaping, because JSON.stringify leaves
|
||||
// "<", ">" and "&" alone and display names may carry them. The encoder's
|
||||
// trailing newline is dropped.
|
||||
func marshalJSON(value any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil
|
||||
}
|
||||
|
||||
func (f fields) has(key string) bool {
|
||||
_, ok := f[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (f fields) isNull(key string) bool {
|
||||
raw, ok := f[key]
|
||||
return ok && string(bytes.TrimSpace(raw)) == "null"
|
||||
}
|
||||
|
||||
// require mirrors a plain required zod field: present and not null.
|
||||
func (f fields) require(keys ...string) error {
|
||||
for _, key := range keys {
|
||||
if !f.has(key) {
|
||||
return fmt.Errorf("%s is required", key)
|
||||
}
|
||||
if f.isNull(key) {
|
||||
return fmt.Errorf("%s must not be null", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireNullable mirrors `.nullable()` without `.optional()`: the key must be
|
||||
// present but may be null.
|
||||
func (f fields) requireNullable(keys ...string) error {
|
||||
for _, key := range keys {
|
||||
if !f.has(key) {
|
||||
return fmt.Errorf("%s is required", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// optional mirrors `.optional()` without `.nullable()`: null is rejected, an
|
||||
// absent key is not.
|
||||
func (f fields) optional(keys ...string) error {
|
||||
for _, key := range keys {
|
||||
if f.isNull(key) {
|
||||
return fmt.Errorf("%s must not be null", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Int is a JSON number restricted to an integral value, mirroring zod's
|
||||
// z.number().int(): 5.0 is an integer, 5.5 is not, and values outside the safe
|
||||
// integer range are rejected (zod 4 rejects them too, and every `.int()` field
|
||||
// in the contract is bounded by MAX_SAFE_INTEGER anyway).
|
||||
type Int int64
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (v *Int) UnmarshalJSON(data []byte) error {
|
||||
var number float64
|
||||
if err := json.Unmarshal(data, &number); err != nil {
|
||||
return err
|
||||
}
|
||||
if math.IsNaN(number) || math.IsInf(number, 0) ||
|
||||
number != math.Trunc(number) ||
|
||||
number < -MaxSafeInteger || number > MaxSafeInteger {
|
||||
return fmt.Errorf("%s is not a safe integer", string(data))
|
||||
}
|
||||
*v = Int(number)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Num is a JSON number restricted to a finite value, mirroring zod's
|
||||
// z.number().finite().
|
||||
type Num float64
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (v *Num) UnmarshalJSON(data []byte) error {
|
||||
var number float64
|
||||
if err := json.Unmarshal(data, &number); err != nil {
|
||||
return err
|
||||
}
|
||||
if math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return fmt.Errorf("%s is not finite", string(data))
|
||||
}
|
||||
*v = Num(number)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nullable carries a value that is both `.nullable()` and `.optional()`, so the
|
||||
// three wire states (absent, null, value) survive a decode/encode round trip.
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
// IsZero reports whether the key was absent, so `omitzero` drops it.
|
||||
func (n Nullable[T]) IsZero() bool { return !n.Set }
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (n Nullable[T]) MarshalJSON() ([]byte, error) {
|
||||
if n.Value == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return marshalJSON(*n.Value)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
|
||||
n.Set = true
|
||||
n.Value = nil
|
||||
if string(bytes.TrimSpace(data)) == "null" {
|
||||
return nil
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Value = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
// errUnknownVariant is the shared "no union member matched" failure.
|
||||
var errUnknownVariant = errors.New("no matching message variant")
|
||||
|
||||
func enumOf(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inRangeInt(value Int, minimum, maximum int64) bool {
|
||||
return int64(value) >= minimum && int64(value) <= maximum
|
||||
}
|
||||
|
||||
func inRangeNum(value Num, minimum, maximum float64) bool {
|
||||
return float64(value) >= minimum && float64(value) <= maximum
|
||||
}
|
||||
|
||||
// typeOf reads the union discriminator without imposing strictness, the way
|
||||
// zod tries each member of z.union / z.discriminatedUnion.
|
||||
func typeOf(data []byte, key string) (string, error) {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, ok := probe[key]
|
||||
if !ok {
|
||||
return "", errUnknownVariant
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return "", errUnknownVariant
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package protocol is the Go owner of the Screener wire contract. It mirrors
|
||||
// src/shared/protocol.ts (zod schemas) plus the four shared scalar helpers
|
||||
// (media-copy-accounting.ts, nat-candidate.ts, packet-loss.ts,
|
||||
// video-codec-evidence.ts). The package performs no I/O and imports nothing
|
||||
// from other internal packages.
|
||||
package protocol
|
||||
|
||||
// MaxSafeInteger is JavaScript's Number.MAX_SAFE_INTEGER.
|
||||
const MaxSafeInteger = 9007199254740991
|
||||
|
||||
// Limits and identifiers mirrored from src/shared/protocol.ts.
|
||||
const (
|
||||
MaxViewersPerRoomLimit = 20
|
||||
MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1
|
||||
MaxSignalBytes = 64 * 1024
|
||||
SignalingProtocol = "screener-v21"
|
||||
RoomCodeLength = 4
|
||||
MaxMediaRouteRevision = MaxSafeInteger
|
||||
MaxSfuTokenLength = 8 * 1024
|
||||
MaxIceServerURLs = 8
|
||||
|
||||
MaxNatPredictionAuxiliaryStunURLs = 2
|
||||
|
||||
MaxViewerQualityEvidenceBytes = 2 * 1024
|
||||
ViewerQualityEvidenceIntervalMs = 2_000
|
||||
ViewerQualityEvidenceExpiryMs = 5_000
|
||||
PersistentNativeEdgeDegradedWindows = 3
|
||||
|
||||
MaxDisplayNameCodePoints = 24
|
||||
DefaultViewerDisplayName = "观众"
|
||||
DefaultHostDisplayNamePrefix = "分享者"
|
||||
|
||||
MinViewerPasswordLength = 1
|
||||
MaxViewerPasswordLength = 64
|
||||
|
||||
// MaxStunURLLength is stunUrlSchema's .max(512), in UTF-16 code units.
|
||||
MaxStunURLLength = 512
|
||||
)
|
||||
|
||||
// Endpoint media copy accounting bounds, from src/shared/media-copy-accounting.ts.
|
||||
const (
|
||||
DefaultEndpointMediaCopyCapacity = 2
|
||||
MaxEndpointMediaCopyCapacity = 3
|
||||
)
|
||||
|
||||
// SignalCloseCode is one of SIGNAL_CLOSE_CODES.
|
||||
type SignalCloseCode int
|
||||
|
||||
// SIGNAL_CLOSE_CODES.
|
||||
const (
|
||||
SignalCloseServiceRestart SignalCloseCode = 1012
|
||||
SignalCloseSessionReplaced SignalCloseCode = 4001
|
||||
SignalCloseClientReconnect SignalCloseCode = 4002
|
||||
SignalCloseAuthenticationFailed SignalCloseCode = 4003
|
||||
SignalCloseViewerAccessRevoked SignalCloseCode = 4004
|
||||
)
|
||||
|
||||
// NatTraversalPaths mirrors NAT_TRAVERSAL_PATHS in src/shared/nat-candidate.ts.
|
||||
var NatTraversalPaths = [...]string{"unknown", "ordinary", "predicted"}
|
||||
|
||||
// DefaultQualitySettings mirrors DEFAULT_QUALITY_SETTINGS.
|
||||
var DefaultQualitySettings = QualitySettings{
|
||||
Resolution: "1080p",
|
||||
MaxFramerate: 30,
|
||||
MaxBitrate: 5_000_000,
|
||||
DegradationPreference: "balanced",
|
||||
ScreenAudioQuality: "music",
|
||||
}
|
||||
|
||||
// DefaultRoutePolicy mirrors DEFAULT_ROUTE_POLICY.
|
||||
var DefaultRoutePolicy = RoutePolicy{
|
||||
PeerOnly: false,
|
||||
TopologyOptimization: true,
|
||||
NatPrediction: false,
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// member is one JSON object key with its raw encoded value, so the tests can
|
||||
// reproduce the exact wire shapes from tests/protocol.test.ts (including key
|
||||
// presence, which a Go map cannot express).
|
||||
type member struct {
|
||||
key string
|
||||
raw string
|
||||
}
|
||||
|
||||
func object(members []member) []byte {
|
||||
var builder strings.Builder
|
||||
builder.WriteByte('{')
|
||||
for index, entry := range members {
|
||||
if index > 0 {
|
||||
builder.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&builder, "%q:%s", entry.key, entry.raw)
|
||||
}
|
||||
builder.WriteByte('}')
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func with(base []member, changes ...member) []member {
|
||||
out := append([]member(nil), base...)
|
||||
for _, change := range changes {
|
||||
replaced := false
|
||||
for index := range out {
|
||||
if out[index].key == change.key {
|
||||
out[index] = change
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
out = append(out, change)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func drop(base []member, keys ...string) []member {
|
||||
out := make([]member, 0, len(base))
|
||||
for _, entry := range base {
|
||||
remove := false
|
||||
for _, key := range keys {
|
||||
if entry.key == key {
|
||||
remove = true
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
testAssignmentNone = `{"upstream":{"kind":"none"},"childPeerIds":[],` +
|
||||
`"sfuPublicationGeneration":null}`
|
||||
testQualitySettings = `{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,` +
|
||||
`"degradationPreference":"maintain-resolution"}`
|
||||
testRoutePolicy = `{"peerOnly":false,"topologyOptimization":true,"natPrediction":false}`
|
||||
testIceConfig = `{"iceServers":[],"natPredictionStunUrls":[]}`
|
||||
)
|
||||
|
||||
func authenticatedHost() []member {
|
||||
return []member{
|
||||
{"type", `"authenticated"`},
|
||||
{"protocol", `"screener-v21"`},
|
||||
{"peerId", `"host_12345678"`},
|
||||
{"roomExpiresAt", `"2026-08-18T18:00:00.000Z"`},
|
||||
{"maxViewers", `8`},
|
||||
{"endpointMediaCopyCapacity", `2`},
|
||||
{"hostOnline", `true`},
|
||||
{"connectionId", `null`},
|
||||
{"iceConfig", testIceConfig},
|
||||
{"routePolicy", testRoutePolicy},
|
||||
{"codeEntryPolicy", `"open"`},
|
||||
{"viewerAuthorizationGeneration", `"viewer_generation_12345678"`},
|
||||
{"mediaMode", `"peer-assisted"`},
|
||||
{"shareGeneration", `"share_generation_12345678"`},
|
||||
{"routeRevision", `0`},
|
||||
{"routeAssignment", testAssignmentNone},
|
||||
{"qualitySettings", testQualitySettings},
|
||||
{"role", `"host"`},
|
||||
{"viewerPasswordEnabled", `false`},
|
||||
}
|
||||
}
|
||||
|
||||
func assertServerMessage(t *testing.T, name string, data []byte, want bool) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := DecodeServerMessage(data)
|
||||
if want && err != nil {
|
||||
t.Fatalf("expected %s to decode, got %v", data, err)
|
||||
}
|
||||
if !want && err == nil {
|
||||
t.Fatalf("expected %s to be rejected", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertClientMessage(t *testing.T, name string, data []byte, want bool) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := DecodeClientMessage(data)
|
||||
if want && err != nil {
|
||||
t.Fatalf("expected %s to decode, got %v", data, err)
|
||||
}
|
||||
if !want && err == nil {
|
||||
t.Fatalf("expected %s to be rejected", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// "accepts dynamic viewer limits within the protocol boundary" and
|
||||
// "rejects viewer limit %s outside the protocol boundary".
|
||||
func TestAuthenticatedViewerLimits(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{{`1`, true}, {`8`, true}, {`20`, true}, {`0`, false}, {`1.5`, false}, {`21`, false}} {
|
||||
assertServerMessage(t, "maxViewers "+sample.raw,
|
||||
object(with(authenticatedHost(), member{"maxViewers", sample.raw})), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
// "requires the authenticated endpoint capacity to be 1, 2, or 3".
|
||||
func TestAuthenticatedEndpointCapacity(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{{`1`, true}, {`2`, true}, {`3`, true}, {`0`, false}, {`4`, false}, {`1.5`, false}} {
|
||||
assertServerMessage(t, "capacity "+sample.raw,
|
||||
object(with(authenticatedHost(),
|
||||
member{"endpointMediaCopyCapacity", sample.raw})), sample.want)
|
||||
}
|
||||
assertServerMessage(t, "capacity absent",
|
||||
object(drop(authenticatedHost(), "endpointMediaCopyCapacity")), false)
|
||||
}
|
||||
|
||||
// "scopes password configuration state to authenticated Hosts".
|
||||
func TestAuthenticatedPasswordStateIsHostScoped(t *testing.T) {
|
||||
assertServerMessage(t, "host carries the flag", object(authenticatedHost()), true)
|
||||
assertServerMessage(t, "host without the flag",
|
||||
object(drop(authenticatedHost(), "viewerPasswordEnabled")), false)
|
||||
|
||||
viewer := drop(with(authenticatedHost(),
|
||||
member{"role", `"viewer"`}, member{"peerId", `"viewer_12345678"`}),
|
||||
"viewerPasswordEnabled")
|
||||
assertServerMessage(t, "viewer without the flag", object(viewer), true)
|
||||
assertServerMessage(t, "viewer with the flag",
|
||||
object(with(viewer, member{"viewerPasswordEnabled", `true`})), false)
|
||||
assertServerMessage(t, "viewer with hostPaused",
|
||||
object(with(viewer, member{"hostPaused", `true`})), true)
|
||||
}
|
||||
|
||||
// "represents persistent rooms without a room expiry" and the revision bound.
|
||||
func TestAuthenticatedRoomLifetimeAndRevision(t *testing.T) {
|
||||
assertServerMessage(t, "no expiry",
|
||||
object(with(authenticatedHost(), member{"roomExpiresAt", `null`})), true)
|
||||
assertServerMessage(t, "revision at the ceiling",
|
||||
object(with(authenticatedHost(), member{"routeRevision", `9007199254740991`})), true)
|
||||
assertServerMessage(t, "revision past the ceiling",
|
||||
object(with(authenticatedHost(), member{"routeRevision", `9007199254740992`})), false)
|
||||
assertServerMessage(t, "absent route assignment",
|
||||
object(drop(authenticatedHost(), "routeAssignment")), false)
|
||||
assertServerMessage(t, "absent media mode",
|
||||
object(drop(authenticatedHost(), "mediaMode")), false)
|
||||
assertServerMessage(t, "sfu standby url",
|
||||
object(with(authenticatedHost(),
|
||||
member{"sfuStandbyUrl", `"wss://sfu.example.test"`})), true)
|
||||
assertServerMessage(t, "sfu standby url over https",
|
||||
object(with(authenticatedHost(),
|
||||
member{"sfuStandbyUrl", `"https://sfu.example.test"`})), false)
|
||||
assertServerMessage(t, "quality settings past the bitrate ceiling",
|
||||
object(with(authenticatedHost(), member{"qualitySettings",
|
||||
`{"resolution":"1080p","maxFramerate":60,"maxBitrate":20000000,` +
|
||||
`"degradationPreference":"maintain-resolution"}`})), false)
|
||||
}
|
||||
|
||||
// "accepts only STUN URLs in the authenticated peer ICE config" and
|
||||
// "requires the v20 NAT observation configuration".
|
||||
func TestAuthenticatedIceConfig(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"empty", `{"iceServers":[],"natPredictionStunUrls":[]}`, true},
|
||||
{"one stun url", `{"iceServers":[{"urls":"stun:stun.example.test:3478"}],` +
|
||||
`"natPredictionStunUrls":[]}`, true},
|
||||
{"a list of stun urls", `{"iceServers":[{"urls":["stun:a.example.test:3478",` +
|
||||
`"stun:b.example.test:3478"]}],"natPredictionStunUrls":[]}`, true},
|
||||
{"missing nat prediction urls", `{"iceServers":[]}`, false},
|
||||
{"turn url", `{"iceServers":[{"urls":"turn:relay.example.test:3478?transport=udp"}],` +
|
||||
`"natPredictionStunUrls":[]}`, false},
|
||||
{"turn credentials", `{"iceServers":[{"urls":"turn:relay.example.test:3478",` +
|
||||
`"username":"1787076000","credential":"credential"}],` +
|
||||
`"natPredictionStunUrls":[]}`, false},
|
||||
{"stuns url", `{"iceServers":[{"urls":"stuns:stun.example.test:5349"}],` +
|
||||
`"natPredictionStunUrls":[]}`, false},
|
||||
{"too many nat prediction urls", `{"iceServers":[],"natPredictionStunUrls":` +
|
||||
`["stun:a.example.test:3478","stun:b.example.test:3478",` +
|
||||
`"stun:c.example.test:3478"]}`, false},
|
||||
} {
|
||||
assertServerMessage(t, "iceConfig "+sample.name,
|
||||
object(with(authenticatedHost(), member{"iceConfig", sample.raw})), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
// "keeps Viewer route status as a strict crossed union".
|
||||
func TestRouteStatusCrossedUnion(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"waiting on admission",
|
||||
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission"}`, true},
|
||||
{"failed on exhaustion",
|
||||
`{"type":"route-status","revision":4,"state":"failed","reason":"route-exhausted"}`, true},
|
||||
{"waiting on exhaustion",
|
||||
`{"type":"route-status","revision":3,"state":"waiting","reason":"route-exhausted"}`, false},
|
||||
{"failed on admission",
|
||||
`{"type":"route-status","revision":3,"state":"failed","reason":"sfu-admission"}`, false},
|
||||
{"with a retry hint",
|
||||
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` +
|
||||
`"retryAfterMs":1000}`, false},
|
||||
{"with a peer id",
|
||||
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` +
|
||||
`"peerId":"private-peer-id"}`, false},
|
||||
} {
|
||||
assertServerMessage(t, sample.name, []byte(sample.raw), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
// "accepts a strict, unique and bounded Viewer presence snapshot".
|
||||
func TestViewerPresence(t *testing.T) {
|
||||
const viewer = `{"role":"viewer","peerId":"viewer_12345678","displayName":"小明",` +
|
||||
`"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"}}`
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"sfu upstream with mediaReady",
|
||||
`{"type":"viewer-presence","viewers":[{"role":"viewer","peerId":"viewer_12345678",` +
|
||||
`"displayName":"小明","upstream":{"kind":"sfu"},"mediaReady":true}]}`, true},
|
||||
{"mediaReady false", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
||||
`"peerId":"viewer_12345678","displayName":"小明",` +
|
||||
`"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"},` +
|
||||
`"mediaReady":false}]}`, false},
|
||||
{"duplicate peer ids",
|
||||
`{"type":"viewer-presence","viewers":[` + viewer + `,` + viewer + `]}`, false},
|
||||
{"leaked topology", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
||||
`"peerId":"viewer_12345678","displayName":"小明",` +
|
||||
`"upstream":{"kind":"none"},"mediaTopology":"peer-relay"}]}`, false},
|
||||
{"leaked address", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
||||
`"peerId":"viewer_12345678","displayName":"小明",` +
|
||||
`"upstream":{"kind":"none"},"ip":"203.0.113.1"}]}`, false},
|
||||
{"host entry", `{"type":"viewer-presence","viewers":[` + viewer + `,` +
|
||||
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
||||
`"upstream":{"kind":"none"}}]}`, true},
|
||||
{"host entry with an upstream", `{"type":"viewer-presence","viewers":[` +
|
||||
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
||||
`"upstream":{"kind":"sfu"}}]}`, false},
|
||||
{"host entry with mediaReady", `{"type":"viewer-presence","viewers":[` +
|
||||
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
||||
`"upstream":{"kind":"none"},"mediaReady":true}]}`, false},
|
||||
} {
|
||||
assertServerMessage(t, sample.name, []byte(sample.raw), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerPresenceConstructors(t *testing.T) {
|
||||
host := HostPresenceEntry("host_12345678", "分享者")
|
||||
if host.Upstream != NoUpstream() || host.MediaReady {
|
||||
t.Fatalf("host presence entry must carry {kind:\"none\"} and no mediaReady: %+v", host)
|
||||
}
|
||||
encoded, err := marshalJSON(NewViewerPresenceEntry(
|
||||
"viewer_12345678", "小明", SfuUpstream(), false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(encoded, []byte("mediaReady")) {
|
||||
t.Fatalf("mediaReady must be omitted when false: %s", encoded)
|
||||
}
|
||||
encoded, err = marshalJSON(NewViewerPresenceEntry(
|
||||
"viewer_12345678", "小明", SfuUpstream(), true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(encoded, []byte(`"mediaReady":true`)) {
|
||||
t.Fatalf("mediaReady must be emitted when true: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// "accepts only the privacy-safe route snapshot payload" plus the superRefine.
|
||||
func TestRouteDiagnosticSnapshot(t *testing.T) {
|
||||
const childOne = `{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,` +
|
||||
`"childCount":1,"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,` +
|
||||
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct","rejectionBucket":"none",` +
|
||||
`"quality":{"eligibleWindows":3,"eligibleDurationMs":6000,"freezeWindows":1,` +
|
||||
`"freezeCount":1,"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}`
|
||||
childTwo := func(parent string) string {
|
||||
return `{"ordinal":2,"parent":` + parent + `,"effectiveCapacity":0,"childCount":0,` +
|
||||
`"demandAgeMs":90,"queueWaitMs":null,"candidateStartMs":null,` +
|
||||
`"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"waiting",` +
|
||||
`"rejectionBucket":"sfu-admission","quality":null}`
|
||||
}
|
||||
snapshot := func(children, operation string) []byte {
|
||||
return []byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":` +
|
||||
children + `,"operation":` + operation + `}}`)
|
||||
}
|
||||
const operation = `{"childOrdinal":2,"reason":"join","stage":"admission",` +
|
||||
`"cursor":1,"candidateCount":2}`
|
||||
|
||||
assertServerMessage(t, "full snapshot",
|
||||
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`, operation), true)
|
||||
assertServerMessage(t, "empty snapshot", snapshot(`[]`, `null`), true)
|
||||
assertServerMessage(t, "leaked peer id",
|
||||
[]byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":[],`+
|
||||
`"operation":null,"peerId":"viewer_private_12345678"}}`), false)
|
||||
assertServerMessage(t, "leaked connection id",
|
||||
snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+
|
||||
`"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+
|
||||
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+
|
||||
`"rejectionBucket":"none","quality":null,`+
|
||||
`"connectionId":"connection_private_12345678"}]`, `null`), false)
|
||||
assertServerMessage(t, "negative freeze count",
|
||||
snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+
|
||||
`"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+
|
||||
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+
|
||||
`"rejectionBucket":"none","quality":{"eligibleWindows":3,`+
|
||||
`"eligibleDurationMs":6000,"freezeWindows":1,"freezeCount":-1,`+
|
||||
`"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}]`, `null`), false)
|
||||
assertServerMessage(t, "self parent",
|
||||
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":2}`)+`]`, `null`), false)
|
||||
assertServerMessage(t, "unknown parent ordinal",
|
||||
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":7}`)+`]`, `null`), false)
|
||||
assertServerMessage(t, "duplicate ordinals",
|
||||
snapshot(`[`+childOne+`,`+childOne+`]`, `null`), false)
|
||||
assertServerMessage(t, "operation names an absent child",
|
||||
snapshot(`[`+childOne+`]`, operation), false)
|
||||
assertServerMessage(t, "cursor past the candidate count",
|
||||
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`,
|
||||
`{"childOrdinal":2,"reason":"join","stage":"admission","cursor":2,`+
|
||||
`"candidateCount":2}`), false)
|
||||
assertServerMessage(t, "raw rejection bucket on the operation",
|
||||
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`,
|
||||
`{"childOrdinal":2,"reason":"join","stage":"admission","cursor":1,`+
|
||||
`"candidateCount":2,"rejectionBucket":"raw-error"}`), false)
|
||||
assertServerMessage(t, "parent ordinal on a host parent",
|
||||
snapshot(`[{"ordinal":1,"parent":{"kind":"host","ordinal":1},"effectiveCapacity":2,`+
|
||||
`"childCount":1,"demandAgeMs":null,"queueWaitMs":null,"candidateStartMs":null,`+
|
||||
`"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"direct",`+
|
||||
`"rejectionBucket":"none","quality":null}]`, `null`), false)
|
||||
}
|
||||
|
||||
// "keeps route messages strict alongside the single authenticated mode" and
|
||||
// "accepts every bounded hybrid upstream shape".
|
||||
func TestParticipantRouteAssignment(t *testing.T) {
|
||||
routeUpdate := func(assignment string) []byte {
|
||||
return []byte(`{"type":"route-update","revision":9,"phase":"active","assignment":` +
|
||||
assignment + `}`)
|
||||
}
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
assignment string
|
||||
want bool
|
||||
}{
|
||||
{"no upstream", testAssignmentNone, true},
|
||||
{"peer upstream", `{"upstream":{"kind":"peer","peerId":"parent_12345678"},` +
|
||||
`"childPeerIds":["child_12345678","child_87654321"],` +
|
||||
`"sfuPublicationGeneration":null}`, true},
|
||||
{"sfu upstream", `{"upstream":{"kind":"sfu"},"childPeerIds":[],` +
|
||||
`"sfuPublicationGeneration":"generation_12345678"}`, true},
|
||||
{"sfu upstream without a generation", `{"upstream":{"kind":"sfu"},` +
|
||||
`"childPeerIds":[],"sfuPublicationGeneration":null}`, false},
|
||||
{"peer upstream owning a generation",
|
||||
`{"upstream":{"kind":"peer","peerId":"parent_12345678"},"childPeerIds":[],` +
|
||||
`"sfuPublicationGeneration":"generation_12345678"}`, false},
|
||||
{"too many children", `{"upstream":{"kind":"none"},"childPeerIds":` +
|
||||
`["child_12345678","child_87654321","child_third_1234","child_overflow_1"],` +
|
||||
`"sfuPublicationGeneration":null}`, false},
|
||||
{"duplicate children", `{"upstream":{"kind":"none"},` +
|
||||
`"childPeerIds":["child_12345678","child_12345678"],` +
|
||||
`"sfuPublicationGeneration":null}`, false},
|
||||
{"absent generation key", `{"upstream":{"kind":"none"},"childPeerIds":[]}`, false},
|
||||
{"peer id on a none upstream", `{"upstream":{"kind":"none",` +
|
||||
`"peerId":"parent_12345678"},"childPeerIds":[],` +
|
||||
`"sfuPublicationGeneration":null}`, false},
|
||||
} {
|
||||
assertServerMessage(t, "assignment "+sample.name,
|
||||
routeUpdate(sample.assignment), sample.want)
|
||||
}
|
||||
|
||||
assertServerMessage(t, "prepare without a candidate",
|
||||
[]byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+
|
||||
testAssignmentNone+`}`), false)
|
||||
assertServerMessage(t, "active with a candidate",
|
||||
[]byte(`{"type":"route-update","revision":9,"phase":"active","assignment":`+
|
||||
testAssignmentNone+`,"candidate":{"childPeerId":"child_12345678",`+
|
||||
`"connectionId":"connection_12345678","transport":"direct","qualityProbe":false}}`),
|
||||
false)
|
||||
assertServerMessage(t, "prepare with a room-wide field",
|
||||
[]byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+
|
||||
testAssignmentNone+`,"roomAssignments":[]}`), false)
|
||||
}
|
||||
|
||||
// "accepts only bounded connection-attempt progress on a prepared route".
|
||||
func TestPreparedRouteCandidate(t *testing.T) {
|
||||
prepared := func(candidate string) []byte {
|
||||
return []byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":` +
|
||||
testAssignmentNone + `,"candidate":` + candidate + `}`)
|
||||
}
|
||||
const base = `"childPeerId":"child_12345678","connectionId":"connection_12345678",` +
|
||||
`"transport":"direct","qualityProbe":false`
|
||||
for _, sample := range []struct {
|
||||
attempt string
|
||||
want bool
|
||||
}{
|
||||
{`{"current":1,"total":3}`, true},
|
||||
{`{"current":2,"total":3}`, true},
|
||||
{`{"current":3,"total":3}`, true},
|
||||
{`{"current":0,"total":3}`, false},
|
||||
{`{"current":4,"total":3}`, false},
|
||||
{`{"current":1,"total":4}`, false},
|
||||
{`{"current":1.5,"total":3}`, false},
|
||||
} {
|
||||
assertServerMessage(t, "connectionAttempt "+sample.attempt,
|
||||
prepared(`{`+base+`,"connectionAttempt":`+sample.attempt+`}`), sample.want)
|
||||
}
|
||||
assertServerMessage(t, "candidate without an attempt", prepared(`{`+base+`}`), true)
|
||||
assertServerMessage(t, "candidate with an unknown key",
|
||||
prepared(`{`+base+`,"unexpected":null}`), false)
|
||||
assertServerMessage(t, "candidate with a null attempt",
|
||||
prepared(`{`+base+`,"connectionAttempt":null}`), false)
|
||||
}
|
||||
|
||||
// "accepts only canonical server-derived viewer evidence envelopes" and
|
||||
// "keeps route messages strict" for sfu-config.
|
||||
func TestForwardedEvidenceAndSfuConfig(t *testing.T) {
|
||||
const metrics = `{"natTraversalPath":"ordinary","width":null,"height":null,` +
|
||||
`"framesPerSecond":null,"bitrateKbps":null,"packetsReceivedDelta":null,` +
|
||||
`"packetsLostDelta":null,"rttMs":null,"jitterMs":null,"framesDecodedDelta":null,` +
|
||||
`"framesDroppedDelta":null,"decodeMsPerFrame":null,"freezeCountDelta":null,` +
|
||||
`"freezeDurationMsDelta":null,"pauseCountDelta":null,"pauseDurationMsDelta":null,` +
|
||||
`"codec":null,"codecProfile":null,"codecParameters":null,"audioBitrateKbps":null,` +
|
||||
`"audioPacketLossPercent":null,"audioJitterMs":null,"audioVideoPlayoutDeltaMs":null,` +
|
||||
`"videoJitterBufferDelayMs":null,"audioJitterBufferDelayMs":null,` +
|
||||
`"audioConcealedSamplesPercent":null,"audioConcealmentEventsDelta":null,` +
|
||||
`"audioCodec":null}`
|
||||
forwarded := func(extra string, upstream string) []byte {
|
||||
return []byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",` +
|
||||
`"upstream":` + upstream + `,"guard":{"connectionId":"connection_12345678",` +
|
||||
`"routeRevision":0,"presentationEpoch":0},"sequence":0,"windowMs":2000,` +
|
||||
`"metrics":` + metrics + extra + `}`)
|
||||
}
|
||||
assertServerMessage(t, "peer upstream",
|
||||
forwarded("", `{"kind":"peer","peerId":"host_12345678"}`), true)
|
||||
assertServerMessage(t, "sfu upstream", forwarded("", `{"kind":"sfu"}`), true)
|
||||
assertServerMessage(t, "no upstream", forwarded("", `{"kind":"none"}`), false)
|
||||
assertServerMessage(t, "leaked room id",
|
||||
forwarded(`,"roomId":"1234"`, `{"kind":"sfu"}`), false)
|
||||
assertServerMessage(t, "missing upstream",
|
||||
[]byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",`+
|
||||
`"guard":{"connectionId":"connection_12345678","routeRevision":0,`+
|
||||
`"presentationEpoch":0},"sequence":0,"windowMs":2000,"metrics":`+metrics+`}`), false)
|
||||
|
||||
assertServerMessage(t, "sfu config",
|
||||
[]byte(`{"type":"sfu-config","revision":9,"url":"wss://sfu.example.test",`+
|
||||
`"token":"header.payload.signature"}`), true)
|
||||
assertServerMessage(t, "sfu config with an oversized token",
|
||||
[]byte(`{"type":"sfu-config","revision":9,"url":"wss://sfu.example.test","token":"`+
|
||||
repeat("x", MaxSfuTokenLength+1)+`"}`), false)
|
||||
}
|
||||
|
||||
// zod's z.number().int() accepts an integral JSON number in any spelling.
|
||||
func TestIntAcceptsIntegralNumberForms(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{`7`, true}, {`7.0`, true}, {`7e0`, true}, {`1e3`, true},
|
||||
{`7.5`, false}, {`"7"`, false}, {`true`, false},
|
||||
} {
|
||||
assertClientMessage(t, "revision "+sample.raw,
|
||||
[]byte(`{"type":"route-media-unavailable","revision":`+sample.raw+`}`), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON.parse keeps the last occurrence of a duplicate key.
|
||||
func TestDuplicateKeysKeepTheLastOccurrence(t *testing.T) {
|
||||
message, err := DecodeClientMessage(
|
||||
[]byte(`{"type":"relay-capacity","downstreamEdges":9,"downstreamEdges":2}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the last occurrence must win: %v", err)
|
||||
}
|
||||
if capacity, ok := message.(RelayCapacityMessage); !ok || capacity.DownstreamEdges != 2 {
|
||||
t.Fatalf("decoded %#v, want downstreamEdges 2", message)
|
||||
}
|
||||
if _, err := DecodeClientMessage(
|
||||
[]byte(`{"type":"relay-capacity","downstreamEdges":2,"downstreamEdges":9}`)); err == nil {
|
||||
t.Fatal("the last occurrence must be the one validated")
|
||||
}
|
||||
// A duplicated object is replaced, not merged.
|
||||
if _, err := DecodeClientMessage(
|
||||
[]byte(`{"type":"set-quality-settings","qualitySettings":{"resolution":"480p"},` +
|
||||
`"qualitySettings":` + testQualitySettings + `}`)); err != nil {
|
||||
t.Fatalf("a duplicated object must be replaced wholesale: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON.stringify does not escape "<", ">" or "&"; display names may hold them.
|
||||
func TestEncodeServerMessageDoesNotEscapeHTML(t *testing.T) {
|
||||
encoded, err := EncodeServerMessage(ViewerPresenceMessage{
|
||||
Type: "viewer-presence",
|
||||
Viewers: []ParticipantPresenceEntry{
|
||||
HostPresenceEntry("host_12345678", "a<b>&c"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(encoded, []byte(`"displayName":"a<b>&c"`)) {
|
||||
t.Fatalf("display name must survive unescaped: %s", encoded)
|
||||
}
|
||||
if bytes.HasSuffix(encoded, []byte("\n")) {
|
||||
t.Fatalf("the encoder newline must be dropped: %q", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty collections encode as [] the way the TypeScript literals do.
|
||||
func TestEncodeServerMessageEmitsEmptyArrays(t *testing.T) {
|
||||
encoded, err := EncodeServerMessage(ViewerPresenceMessage{Type: "viewer-presence"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(encoded) != `{"type":"viewer-presence","viewers":[]}` {
|
||||
t.Fatalf("got %s", encoded)
|
||||
}
|
||||
encoded, err = EncodeServerMessage(RouteDiagnosticSnapshotMessage{
|
||||
Type: "route-diagnostic-snapshot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(encoded) !=
|
||||
`{"type":"route-diagnostic-snapshot","snapshot":{"children":[],"operation":null}}` {
|
||||
t.Fatalf("got %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// The authenticated key order follows the object literal in signaling.ts.
|
||||
func TestAuthenticatedKeyOrder(t *testing.T) {
|
||||
standby := "wss://sfu.example.test"
|
||||
paused := true
|
||||
encoded, err := EncodeServerMessage(AuthenticatedViewerMessage{
|
||||
Type: "authenticated", Protocol: SignalingProtocol, PeerID: "viewer_12345678",
|
||||
MaxViewers: 8, EndpointMediaCopyCapacity: 2, HostOnline: true, HostPaused: &paused,
|
||||
IceConfig: IceConfig{}, RoutePolicy: DefaultRoutePolicy, CodeEntryPolicy: CodeEntryOpen,
|
||||
ViewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
MediaMode: "peer-assisted", RouteRevision: 0,
|
||||
QualitySettings: DefaultQualitySettings, SfuStandbyURL: &standby, Role: RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order = append(order, key.(string))
|
||||
var skip json.RawMessage
|
||||
if err := decoder.Decode(&skip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
"type", "protocol", "peerId", "roomExpiresAt", "maxViewers",
|
||||
"endpointMediaCopyCapacity", "hostOnline", "hostPaused", "connectionId",
|
||||
"iceConfig", "routePolicy", "codeEntryPolicy", "viewerAuthorizationGeneration",
|
||||
"mediaMode", "shareGeneration", "routeRevision", "routeAssignment",
|
||||
"qualitySettings", "sfuStandbyUrl", "role",
|
||||
}
|
||||
if strings.Join(order, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("key order\n got: %v\nwant: %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional keys reject an explicit null; nullable keys reject absence.
|
||||
func TestOptionalAndNullableAreExact(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"null optional shareGeneration",
|
||||
`{"type":"stop-sharing","shareGeneration":null}`, false},
|
||||
{"absent optional shareGeneration", `{"type":"stop-sharing"}`, true},
|
||||
{"null optional targetPeerId",
|
||||
`{"type":"restart-request","targetPeerId":null,` +
|
||||
`"connectionId":"connection_12345678","rebuild":true}`, false},
|
||||
{"null optional qualityApproved",
|
||||
`{"type":"route-ready","revision":1,"phase":"prepare","qualityApproved":null}`, false},
|
||||
{"null nullable connectionId",
|
||||
`{"type":"route-failed","revision":1,"phase":"active","connectionId":null}`, true},
|
||||
{"absent nullable connectionId",
|
||||
`{"type":"route-failed","revision":1,"phase":"active"}`, false},
|
||||
} {
|
||||
assertClientMessage(t, sample.name, []byte(sample.raw), sample.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
|
||||
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
|
||||
message, err := DecodeClientMessage([]byte(
|
||||
`{"type":"authenticate","protocol":"screener-v21","roomId":"1234","role":"host",` +
|
||||
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, ok := message.(AuthenticateHostMessage)
|
||||
if !ok {
|
||||
t.Fatalf("decoded %#v, want AuthenticateHostMessage", message)
|
||||
}
|
||||
if host.RoutePolicy != DefaultRoutePolicy {
|
||||
t.Fatalf("routePolicy = %+v, want %+v", host.RoutePolicy, DefaultRoutePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// The sealed unions accept nothing outside their own message structs.
|
||||
func TestDecodeRejectsUnknownEnvelopes(t *testing.T) {
|
||||
for _, raw := range []string{`{}`, `[]`, `null`, `"authenticate"`, `{"type":42}`,
|
||||
`{"type":"unknown"}`, `{"kind":"signal"}`} {
|
||||
if _, err := DecodeClientMessage([]byte(raw)); err == nil {
|
||||
t.Errorf("client decode accepted %s", raw)
|
||||
}
|
||||
if _, err := DecodeServerMessage([]byte(raw)); err == nil {
|
||||
t.Errorf("server decode accepted %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fixture is the single owner of the cross-language wire samples;
|
||||
// tests/protocol-fixture.test.ts replays the same file against the zod schemas.
|
||||
const fixturePath = "../../../tests/fixtures/wire-samples.json"
|
||||
|
||||
type wireFixture struct {
|
||||
Constants map[string]any `json:"constants"`
|
||||
ClientMessages []struct {
|
||||
Name string `json:"name"`
|
||||
Schema string `json:"schema"`
|
||||
JSON string `json:"json"`
|
||||
Valid bool `json:"valid"`
|
||||
} `json:"clientMessages"`
|
||||
ServerMessages []struct {
|
||||
Name string `json:"name"`
|
||||
JSON string `json:"json"`
|
||||
} `json:"serverMessages"`
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T) wireFixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.FromSlash(fixturePath))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
var fixture wireFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatalf("parse fixture: %v", err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
func TestFixtureConstants(t *testing.T) {
|
||||
fixture := loadFixture(t)
|
||||
expected := map[string]any{
|
||||
"signalingProtocol": SignalingProtocol,
|
||||
"maxViewersPerRoomLimit": float64(MaxViewersPerRoomLimit),
|
||||
"maxParticipantsPerRoomLimit": float64(MaxParticipantsPerRoomLimit),
|
||||
"maxSignalBytes": float64(MaxSignalBytes),
|
||||
"roomCodeLength": float64(RoomCodeLength),
|
||||
"maxMediaRouteRevision": float64(MaxMediaRouteRevision),
|
||||
"maxSfuTokenLength": float64(MaxSfuTokenLength),
|
||||
"maxIceServerUrls": float64(MaxIceServerURLs),
|
||||
"maxNatPredictionAuxiliaryStunUrls": float64(MaxNatPredictionAuxiliaryStunURLs),
|
||||
"maxViewerQualityEvidenceBytes": float64(MaxViewerQualityEvidenceBytes),
|
||||
"viewerQualityEvidenceIntervalMs": float64(ViewerQualityEvidenceIntervalMs),
|
||||
"viewerQualityEvidenceExpiryMs": float64(ViewerQualityEvidenceExpiryMs),
|
||||
"persistentNativeEdgeDegradedWindows": float64(PersistentNativeEdgeDegradedWindows),
|
||||
"maxDisplayNameCodePoints": float64(MaxDisplayNameCodePoints),
|
||||
"defaultViewerDisplayName": DefaultViewerDisplayName,
|
||||
"defaultHostDisplayNamePrefix": DefaultHostDisplayNamePrefix,
|
||||
"minViewerPasswordLength": float64(MinViewerPasswordLength),
|
||||
"maxViewerPasswordLength": float64(MaxViewerPasswordLength),
|
||||
"defaultEndpointMediaCopyCapacity": float64(DefaultEndpointMediaCopyCapacity),
|
||||
"maxEndpointMediaCopyCapacity": float64(MaxEndpointMediaCopyCapacity),
|
||||
"signalCloseCodeServiceRestart": float64(SignalCloseServiceRestart),
|
||||
"signalCloseCodeSessionReplaced": float64(SignalCloseSessionReplaced),
|
||||
"signalCloseCodeClientReconnect": float64(SignalCloseClientReconnect),
|
||||
"signalCloseCodeAuthenticationFailed": float64(SignalCloseAuthenticationFailed),
|
||||
"signalCloseCodeViewerAccessRevoked": float64(SignalCloseViewerAccessRevoked),
|
||||
"defaultQualitySettings": jsonValue(t, DefaultQualitySettings),
|
||||
"defaultRoutePolicy": jsonValue(t, DefaultRoutePolicy),
|
||||
}
|
||||
if !reflect.DeepEqual(fixture.Constants, expected) {
|
||||
t.Fatalf("constants mismatch\n got: %#v\nwant: %#v", fixture.Constants, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixtureClientSamples(t *testing.T) {
|
||||
fixture := loadFixture(t)
|
||||
if len(fixture.ClientMessages) == 0 {
|
||||
t.Fatal("fixture carries no client samples")
|
||||
}
|
||||
for _, sample := range fixture.ClientMessages {
|
||||
t.Run(sample.Name, func(t *testing.T) {
|
||||
var err error
|
||||
switch sample.Schema {
|
||||
case "client":
|
||||
_, err = DecodeClientMessage([]byte(sample.JSON))
|
||||
case "createRoomRequest":
|
||||
_, err = DecodeCreateRoomRequest([]byte(sample.JSON))
|
||||
case "replaceRoomRequest":
|
||||
_, err = DecodeReplaceRoomRequest([]byte(sample.JSON))
|
||||
case "roomAccessUpdateRequest":
|
||||
_, err = DecodeRoomAccessUpdateRequest([]byte(sample.JSON))
|
||||
default:
|
||||
t.Fatalf("unknown fixture schema %q", sample.Schema)
|
||||
}
|
||||
if sample.Valid && err != nil {
|
||||
t.Fatalf("expected the sample to decode, got %v", err)
|
||||
}
|
||||
if !sample.Valid && err == nil {
|
||||
t.Fatal("expected the sample to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixtureServerSamplesRoundTrip(t *testing.T) {
|
||||
fixture := loadFixture(t)
|
||||
if len(fixture.ServerMessages) == 0 {
|
||||
t.Fatal("fixture carries no server samples")
|
||||
}
|
||||
for _, sample := range fixture.ServerMessages {
|
||||
t.Run(sample.Name, func(t *testing.T) {
|
||||
message, err := DecodeServerMessage([]byte(sample.JSON))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
encoded, err := EncodeServerMessage(message)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
var want, got any
|
||||
if err := json.Unmarshal([]byte(sample.JSON), &want); err != nil {
|
||||
t.Fatalf("parse sample: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &got); err != nil {
|
||||
t.Fatalf("parse re-encoded message: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("round trip changed the message\n got: %s\nwant: %s",
|
||||
encoded, sample.JSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValue(t *testing.T, value any) any {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src/shared/media-copy-accounting.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EndpointMediaCopyPhase is EndpointMediaCopyPhase.
|
||||
type EndpointMediaCopyPhase string
|
||||
|
||||
// Endpoint media copy phases.
|
||||
const (
|
||||
EndpointMediaCopySteady EndpointMediaCopyPhase = "steady"
|
||||
EndpointMediaCopyTransition EndpointMediaCopyPhase = "transition"
|
||||
)
|
||||
|
||||
// ErrEndpointMediaCopyCapacity is the assertEndpointMediaCopyCapacity throw.
|
||||
var ErrEndpointMediaCopyCapacity = errors.New(
|
||||
"Endpoint media copy capacity must be 1, 2, or 3")
|
||||
|
||||
// EndpointMediaCopyLimit ports endpointMediaCopyLimit. It panics on an invalid
|
||||
// capacity, which is where the TypeScript throws.
|
||||
func EndpointMediaCopyLimit(capacity int, phase EndpointMediaCopyPhase) int {
|
||||
if err := AssertEndpointMediaCopyCapacity(capacity); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if phase == EndpointMediaCopySteady {
|
||||
return capacity
|
||||
}
|
||||
return min(capacity+1, MaxEndpointMediaCopyCapacity)
|
||||
}
|
||||
|
||||
// EndpointMediaCopyCountFits ports endpointMediaCopyCountFits.
|
||||
func EndpointMediaCopyCountFits(count, capacity int, phase EndpointMediaCopyPhase) bool {
|
||||
return count >= 0 &&
|
||||
IsEndpointMediaCopyCapacity(capacity) &&
|
||||
count <= EndpointMediaCopyLimit(capacity, phase)
|
||||
}
|
||||
|
||||
// IsEndpointMediaCopyCapacity ports isEndpointMediaCopyCapacity. The
|
||||
// TypeScript Number.isSafeInteger guard is implicit in Go's int.
|
||||
func IsEndpointMediaCopyCapacity(capacity int) bool {
|
||||
return capacity >= 1 && capacity <= MaxEndpointMediaCopyCapacity
|
||||
}
|
||||
|
||||
// AssertEndpointMediaCopyCapacity ports assertEndpointMediaCopyCapacity.
|
||||
func AssertEndpointMediaCopyCapacity(capacity int) error {
|
||||
if !IsEndpointMediaCopyCapacity(capacity) {
|
||||
return ErrEndpointMediaCopyCapacity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src/shared/nat-candidate.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CandidateSignalOrigin is CandidateSignalOrigin.
|
||||
type CandidateSignalOrigin string
|
||||
|
||||
// Candidate signal origins.
|
||||
const (
|
||||
CandidateOriginEnd CandidateSignalOrigin = "end"
|
||||
CandidateOriginOrdinary CandidateSignalOrigin = "ordinary"
|
||||
CandidateOriginPredicted CandidateSignalOrigin = "predicted"
|
||||
)
|
||||
|
||||
// The JavaScript \s class, spelled out because Go's \s is ASCII only.
|
||||
var predictedCandidatePattern = regexp.MustCompile(
|
||||
`(?i)^candidate:s[pm][0-9]+[\t\n\v\f\r \x{00a0}\x{1680}\x{2000}-\x{200a}` +
|
||||
`\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`)
|
||||
|
||||
// CandidateSignalOriginOf ports candidateSignalOrigin. An empty string stands
|
||||
// in for the TypeScript null (both yield "end").
|
||||
func CandidateSignalOriginOf(candidate string) CandidateSignalOrigin {
|
||||
if strings.TrimFunc(candidate, IsJSWhitespace) == "" {
|
||||
return CandidateOriginEnd
|
||||
}
|
||||
if predictedCandidatePattern.MatchString(candidate) {
|
||||
return CandidateOriginPredicted
|
||||
}
|
||||
return CandidateOriginOrdinary
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src/shared/packet-loss.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PacketLossPercentFromDeltas ports packetLossPercentFromDeltas. A nil
|
||||
// argument is the TypeScript null; ok is false where it returns null.
|
||||
func PacketLossPercentFromDeltas(packetsReceivedDelta, packetsLostDelta *float64) (float64, bool) {
|
||||
if packetsReceivedDelta == nil || packetsLostDelta == nil ||
|
||||
*packetsReceivedDelta < 0 || *packetsLostDelta < 0 {
|
||||
return 0, false
|
||||
}
|
||||
packetDelta := *packetsReceivedDelta + *packetsLostDelta
|
||||
if packetDelta <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return (*packetsLostDelta / packetDelta) * 100, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src/shared/video-codec-evidence.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type codecParameterRule struct {
|
||||
profileKey string
|
||||
parameterKeys []string
|
||||
validators map[string]*regexp.Regexp
|
||||
}
|
||||
|
||||
var videoCodecParameterRules = map[string]codecParameterRule{
|
||||
"video/h264": {
|
||||
profileKey: "profile-level-id",
|
||||
parameterKeys: []string{"packetization-mode", "level-asymmetry-allowed"},
|
||||
validators: map[string]*regexp.Regexp{
|
||||
"profile-level-id": regexp.MustCompile(`^[0-9a-f]{6}$`),
|
||||
"packetization-mode": regexp.MustCompile(`^[0-2]$`),
|
||||
"level-asymmetry-allowed": regexp.MustCompile(`^[01]$`),
|
||||
},
|
||||
},
|
||||
"video/vp9": {
|
||||
profileKey: "profile-id",
|
||||
parameterKeys: []string{"max-fr", "max-fs"},
|
||||
validators: map[string]*regexp.Regexp{
|
||||
"profile-id": regexp.MustCompile(`^[0-3]$`),
|
||||
"max-fr": regexp.MustCompile(`^[1-9][0-9]{0,9}$`),
|
||||
"max-fs": regexp.MustCompile(`^[1-9][0-9]{0,9}$`),
|
||||
},
|
||||
},
|
||||
"video/vp8": {
|
||||
profileKey: "",
|
||||
parameterKeys: []string{"max-fr", "max-fs"},
|
||||
validators: map[string]*regexp.Regexp{
|
||||
"max-fr": regexp.MustCompile(`^[1-9][0-9]{0,9}$`),
|
||||
"max-fs": regexp.MustCompile(`^[1-9][0-9]{0,9}$`),
|
||||
},
|
||||
},
|
||||
"video/av1": {
|
||||
profileKey: "profile",
|
||||
parameterKeys: []string{"level-idx", "tier"},
|
||||
validators: map[string]*regexp.Regexp{
|
||||
"profile": regexp.MustCompile(`^[0-2]$`),
|
||||
"level-idx": regexp.MustCompile(`^(?:[0-9]|[12][0-9]|3[01])$`),
|
||||
"tier": regexp.MustCompile(`^[01]$`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var videoMimeTypePattern = regexp.MustCompile(`(?i)^video/[A-Za-z0-9.+-]{1,32}$`)
|
||||
|
||||
// IsCanonicalVideoCodecEvidence ports isCanonicalVideoCodecEvidence. Empty
|
||||
// strings are the TypeScript nulls; the schema rejects a genuinely empty codec
|
||||
// string before this refine runs.
|
||||
func IsCanonicalVideoCodecEvidence(codec, codecProfile, codecParameters string) bool {
|
||||
rule, ok := videoCodecParameterRules[strings.ToLower(codec)]
|
||||
if codec == "" || !ok {
|
||||
return codecProfile == "" && codecParameters == ""
|
||||
}
|
||||
profileKeys := []string{}
|
||||
if rule.profileKey != "" {
|
||||
profileKeys = append(profileKeys, rule.profileKey)
|
||||
}
|
||||
return canonicalValuesMatch(codecProfile, profileKeys, rule.validators) &&
|
||||
canonicalValuesMatch(codecParameters, rule.parameterKeys, rule.validators)
|
||||
}
|
||||
|
||||
func canonicalValuesMatch(
|
||||
encoded string, orderedKeys []string, validators map[string]*regexp.Regexp,
|
||||
) bool {
|
||||
if encoded == "" {
|
||||
return true
|
||||
}
|
||||
segments := strings.Split(encoded, "; ")
|
||||
if len(segments) > len(orderedKeys) {
|
||||
return false
|
||||
}
|
||||
previousKeyIndex := -1
|
||||
for _, segment := range segments {
|
||||
separator := strings.Index(segment, "=")
|
||||
key, candidate := "", ""
|
||||
if separator >= 1 {
|
||||
key, candidate = segment[:separator], segment[separator+1:]
|
||||
}
|
||||
keyIndex := slices.Index(orderedKeys, key)
|
||||
validator, known := validators[key]
|
||||
if keyIndex <= previousKeyIndex || keyIndex < 0 ||
|
||||
!known || !validator.MatchString(candidate) {
|
||||
return false
|
||||
}
|
||||
previousKeyIndex = keyIndex
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEndpointMediaCopyLimits(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
capacity, steady, transition int
|
||||
}{{1, 1, 2}, {2, 2, 3}, {3, 3, 3}} {
|
||||
if got := EndpointMediaCopyLimit(sample.capacity, EndpointMediaCopySteady); got != sample.steady {
|
||||
t.Errorf("steady limit for %d = %d, want %d", sample.capacity, got, sample.steady)
|
||||
}
|
||||
if got := EndpointMediaCopyLimit(sample.capacity, EndpointMediaCopyTransition); got != sample.transition {
|
||||
t.Errorf("transition limit for %d = %d, want %d", sample.capacity, got, sample.transition)
|
||||
}
|
||||
if !EndpointMediaCopyCountFits(sample.transition, sample.capacity, EndpointMediaCopyTransition) {
|
||||
t.Errorf("%d copies must fit capacity %d in transition", sample.transition, sample.capacity)
|
||||
}
|
||||
if EndpointMediaCopyCountFits(sample.transition+1, sample.capacity, EndpointMediaCopyTransition) {
|
||||
t.Errorf("%d copies must not fit capacity %d in transition",
|
||||
sample.transition+1, sample.capacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointMediaCopyCapacityGuards(t *testing.T) {
|
||||
for _, capacity := range []int{0, 4, -1} {
|
||||
if err := AssertEndpointMediaCopyCapacity(capacity); err == nil {
|
||||
t.Errorf("capacity %d must be rejected", capacity)
|
||||
}
|
||||
}
|
||||
// An unavailable runtime capacity returns false instead of throwing.
|
||||
if EndpointMediaCopyCountFits(0, 0, EndpointMediaCopySteady) {
|
||||
t.Error("a zero capacity must never fit")
|
||||
}
|
||||
}
|
||||
|
||||
// Ported from tests/nat-candidate.test.ts.
|
||||
func TestNatCandidateDiagnostics(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
candidate string
|
||||
want CandidateSignalOrigin
|
||||
}{
|
||||
{"", CandidateOriginEnd},
|
||||
{" ", CandidateOriginEnd},
|
||||
{"candidate:sp2 1 udp 1 203.0.113.1 50000 typ srflx", CandidateOriginPredicted},
|
||||
{"candidate:base 1 udp 1 203.0.113.1 50000 typ srflx", CandidateOriginOrdinary},
|
||||
} {
|
||||
if got := CandidateSignalOriginOf(sample.candidate); got != sample.want {
|
||||
t.Errorf("CandidateSignalOriginOf(%q) = %q, want %q",
|
||||
sample.candidate, got, sample.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ported from tests/packet-loss.test.ts ("keeps receive-side delta validation").
|
||||
func TestPacketLossPercentFromDeltas(t *testing.T) {
|
||||
number := func(value float64) *float64 { return &value }
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
received, lost *float64
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"ninety received and ten lost", number(90), number(10), 10, true},
|
||||
{"no packets at all", number(0), number(0), 0, false},
|
||||
{"missing received delta", nil, number(1), 0, false},
|
||||
{"negative lost delta", number(10), number(-1), 0, false},
|
||||
} {
|
||||
t.Run(sample.name, func(t *testing.T) {
|
||||
got, ok := PacketLossPercentFromDeltas(sample.received, sample.lost)
|
||||
if ok != sample.ok || (ok && got != sample.want) {
|
||||
t.Fatalf("PacketLossPercentFromDeltas = (%v, %v), want (%v, %v)",
|
||||
got, ok, sample.want, sample.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ported from the codec cases in tests/protocol.test.ts.
|
||||
func TestIsCanonicalVideoCodecEvidence(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
codec, profile, parameters string
|
||||
want bool
|
||||
}{
|
||||
{"video/H264", "profile-level-id=42e01f",
|
||||
"packetization-mode=1; level-asymmetry-allowed=1", true},
|
||||
{"video/VP8", "", "max-fr=60; max-fs=8160", true},
|
||||
{"video/VP9", "profile-id=2", "max-fs=8160", true},
|
||||
{"video/AV1", "profile=1", "level-idx=31; tier=0", true},
|
||||
{"video/unknown", "", "", true},
|
||||
{"", "", "", true},
|
||||
{"video/unknown", "profile=1", "", false},
|
||||
{"video/H264", "device-id=deadbeef", "", false},
|
||||
{"video/H264", "", "sprop-parameter-sets=deadbeef", false},
|
||||
// Order is part of the canonical form.
|
||||
{"video/H264", "", "level-asymmetry-allowed=1; packetization-mode=1", false},
|
||||
// A duplicate key exceeds the ordered key budget.
|
||||
{"video/VP8", "", "max-fr=60; max-fr=60", false},
|
||||
{"video/AV1", "profile=3", "", false},
|
||||
} {
|
||||
t.Run(sample.codec+"|"+sample.profile+"|"+sample.parameters, func(t *testing.T) {
|
||||
got := IsCanonicalVideoCodecEvidence(sample.codec, sample.profile, sample.parameters)
|
||||
if got != sample.want {
|
||||
t.Fatalf("IsCanonicalVideoCodecEvidence = %v, want %v", got, sample.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CreateRoomRequest mirrors createRoomRequestSchema.
|
||||
type CreateRoomRequest struct {
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
RoomPassword Nullable[string] `json:"roomPassword,omitzero"`
|
||||
PreferredRoomID string `json:"preferredRoomId,omitempty"`
|
||||
}
|
||||
|
||||
// DecodeCreateRoomRequest parses createRoomRequestSchema.
|
||||
func DecodeCreateRoomRequest(data []byte) (CreateRoomRequest, error) {
|
||||
var request CreateRoomRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return CreateRoomRequest{}, err
|
||||
}
|
||||
if err := present.require("codeEntryPolicy"); err != nil {
|
||||
return CreateRoomRequest{}, err
|
||||
}
|
||||
if err := present.optional("preferredRoomId"); err != nil {
|
||||
return CreateRoomRequest{}, err
|
||||
}
|
||||
if !enumOf(request.CodeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) {
|
||||
return CreateRoomRequest{}, fmt.Errorf(
|
||||
"unknown codeEntryPolicy %q", request.CodeEntryPolicy)
|
||||
}
|
||||
if request.RoomPassword.Value != nil && !ValidViewerPassword(*request.RoomPassword.Value) {
|
||||
return CreateRoomRequest{}, errors.New("roomPassword is malformed")
|
||||
}
|
||||
if present.has("preferredRoomId") && !ValidRoomCode(request.PreferredRoomID) {
|
||||
return CreateRoomRequest{}, errors.New("preferredRoomId is not a room code")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// ReplaceRoomRequest mirrors replaceRoomRequestSchema.
|
||||
type ReplaceRoomRequest struct {
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
RoomPassword Nullable[string] `json:"roomPassword,omitzero"`
|
||||
}
|
||||
|
||||
// DecodeReplaceRoomRequest parses replaceRoomRequestSchema.
|
||||
func DecodeReplaceRoomRequest(data []byte) (ReplaceRoomRequest, error) {
|
||||
var request ReplaceRoomRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return ReplaceRoomRequest{}, err
|
||||
}
|
||||
if err := present.require("codeEntryPolicy"); err != nil {
|
||||
return ReplaceRoomRequest{}, err
|
||||
}
|
||||
if !enumOf(request.CodeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) {
|
||||
return ReplaceRoomRequest{}, fmt.Errorf(
|
||||
"unknown codeEntryPolicy %q", request.CodeEntryPolicy)
|
||||
}
|
||||
if request.RoomPassword.Value != nil && !ValidViewerPassword(*request.RoomPassword.Value) {
|
||||
return ReplaceRoomRequest{}, errors.New("roomPassword is malformed")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// RoomAccessUpdateRequest is one member of roomAccessUpdateRequestSchema.
|
||||
type RoomAccessUpdateRequest interface {
|
||||
isRoomAccessUpdateRequest()
|
||||
}
|
||||
|
||||
// SetCodeEntryPolicyRequest is action "set-code-entry-policy".
|
||||
type SetCodeEntryPolicyRequest struct {
|
||||
Action string `json:"action"`
|
||||
Policy CodeEntryPolicy `json:"policy"`
|
||||
}
|
||||
|
||||
// RotateViewerGrantRequest is action "rotate-viewer-grant".
|
||||
type RotateViewerGrantRequest struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// RevokeViewerGrantRequest is action "revoke-viewer-grant".
|
||||
type RevokeViewerGrantRequest struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// SetViewerPasswordRequest is action "set-viewer-password".
|
||||
type SetViewerPasswordRequest struct {
|
||||
Action string `json:"action"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
|
||||
func (SetCodeEntryPolicyRequest) isRoomAccessUpdateRequest() {}
|
||||
func (RotateViewerGrantRequest) isRoomAccessUpdateRequest() {}
|
||||
func (RevokeViewerGrantRequest) isRoomAccessUpdateRequest() {}
|
||||
func (SetViewerPasswordRequest) isRoomAccessUpdateRequest() {}
|
||||
|
||||
// DecodeRoomAccessUpdateRequest parses roomAccessUpdateRequestSchema.
|
||||
func DecodeRoomAccessUpdateRequest(data []byte) (RoomAccessUpdateRequest, error) {
|
||||
action, err := typeOf(data, "action")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch action {
|
||||
case "set-code-entry-policy":
|
||||
var request SetCodeEntryPolicyRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("action", "policy"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enumOf(request.Policy, CodeEntryOpen, CodeEntryPrivate) {
|
||||
return nil, fmt.Errorf("unknown policy %q", request.Policy)
|
||||
}
|
||||
return request, nil
|
||||
case "rotate-viewer-grant":
|
||||
var request RotateViewerGrantRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("action"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return request, nil
|
||||
case "revoke-viewer-grant":
|
||||
var request RevokeViewerGrantRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("action"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return request, nil
|
||||
case "set-viewer-password":
|
||||
var request SetViewerPasswordRequest
|
||||
present, err := decodeObject(data, &request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("action"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.requireNullable("password"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.Password != nil && !ValidViewerPassword(*request.Password) {
|
||||
return nil, errors.New("password is malformed")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown room access action %q", action)
|
||||
}
|
||||
|
||||
// CreateRoomResponse mirrors createRoomResponseSchema.
|
||||
type CreateRoomResponse struct {
|
||||
RoomID string `json:"roomId"`
|
||||
HostToken string `json:"hostToken"`
|
||||
InviteURL string `json:"inviteUrl"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
RoomLeaseSeconds Int `json:"roomLeaseSeconds"`
|
||||
}
|
||||
|
||||
// RoomAccessUpdateResponse is one member of roomAccessUpdateResponseSchema.
|
||||
type RoomAccessUpdateResponse interface {
|
||||
isRoomAccessUpdateResponse()
|
||||
}
|
||||
|
||||
// CodeEntryPolicyUpdatedResponse is type "code-entry-policy-updated".
|
||||
type CodeEntryPolicyUpdatedResponse struct {
|
||||
Type string `json:"type"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
ViewerPasswordEnabled bool `json:"viewerPasswordEnabled"`
|
||||
}
|
||||
|
||||
// ViewerGrantUpdatedResponse is type "viewer-grant-updated". InviteURL is nil
|
||||
// for the explicit null a revoke returns.
|
||||
type ViewerGrantUpdatedResponse struct {
|
||||
Type string `json:"type"`
|
||||
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
|
||||
InviteURL *string `json:"inviteUrl"`
|
||||
}
|
||||
|
||||
// ViewerPasswordUpdatedResponse is type "viewer-password-updated".
|
||||
type ViewerPasswordUpdatedResponse struct {
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (CodeEntryPolicyUpdatedResponse) isRoomAccessUpdateResponse() {}
|
||||
func (ViewerGrantUpdatedResponse) isRoomAccessUpdateResponse() {}
|
||||
func (ViewerPasswordUpdatedResponse) isRoomAccessUpdateResponse() {}
|
||||
@@ -0,0 +1,401 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// UTF16Length counts UTF-16 code units, which is what zod's string .min/.max/
|
||||
// .length and String.prototype.length measure: not bytes, not code points.
|
||||
// It is the single owner of that measurement for the server packages.
|
||||
func UTF16Length(value string) int {
|
||||
length := 0
|
||||
for _, character := range value {
|
||||
if character > 0xffff {
|
||||
length += 2
|
||||
} else {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
// IsJSWhitespace matches the JavaScript regular-expression \s class, which is
|
||||
// also what String.prototype.trim and Number(string) strip. It is the single
|
||||
// owner of that predicate for the server packages.
|
||||
func IsJSWhitespace(character rune) bool {
|
||||
switch character {
|
||||
case '\t', '\n', '\v', '\f', '\r', ' ',
|
||||
0x00a0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff:
|
||||
return true
|
||||
}
|
||||
return character >= 0x2000 && character <= 0x200a
|
||||
}
|
||||
|
||||
var (
|
||||
opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
roomCodePattern = regexp.MustCompile(`^[1-9][0-9]*$`)
|
||||
viewerGrantPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}[AQgw]$`)
|
||||
viewerPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]+$`)
|
||||
)
|
||||
|
||||
// ValidOpaqueID mirrors opaqueIdSchema: 8..128 code units of [A-Za-z0-9_-].
|
||||
func ValidOpaqueID(value string) bool {
|
||||
length := UTF16Length(value)
|
||||
return length >= 8 && length <= 128 && opaqueIDPattern.MatchString(value)
|
||||
}
|
||||
|
||||
// ValidToken mirrors tokenSchema: 32..128 code units of [A-Za-z0-9_-].
|
||||
func ValidToken(value string) bool {
|
||||
length := UTF16Length(value)
|
||||
return length >= 32 && length <= 128 && opaqueIDPattern.MatchString(value)
|
||||
}
|
||||
|
||||
// ValidRoomCode mirrors roomCodeSchema.
|
||||
func ValidRoomCode(value string) bool {
|
||||
return UTF16Length(value) == RoomCodeLength && roomCodePattern.MatchString(value)
|
||||
}
|
||||
|
||||
// ValidViewerGrant mirrors viewerGrantSchema.
|
||||
func ValidViewerGrant(value string) bool {
|
||||
return UTF16Length(value) == 22 && viewerGrantPattern.MatchString(value)
|
||||
}
|
||||
|
||||
// ValidViewerPassword mirrors viewerPasswordSchema.
|
||||
func ValidViewerPassword(value string) bool {
|
||||
length := UTF16Length(value)
|
||||
return length >= MinViewerPasswordLength &&
|
||||
length <= MaxViewerPasswordLength &&
|
||||
viewerPasswordPattern.MatchString(value)
|
||||
}
|
||||
|
||||
// ValidLiveKitWebSocketURL mirrors liveKitWebSocketUrlSchema.
|
||||
func ValidLiveKitWebSocketURL(value string) bool {
|
||||
if UTF16Length(value) > 2048 {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
return scheme == "ws" || scheme == "wss"
|
||||
}
|
||||
|
||||
// isoDateTimePattern mirrors z.string().datetime(): an RFC 3339 UTC instant
|
||||
// with a "Z" designator and no offset.
|
||||
var isoDateTimePattern = regexp.MustCompile(
|
||||
`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`)
|
||||
|
||||
// ValidISODateTime reports whether value is a zod `.datetime()` string.
|
||||
func ValidISODateTime(value string) bool {
|
||||
return isoDateTimePattern.MatchString(value)
|
||||
}
|
||||
|
||||
// forbiddenDisplayNameCharacters is FORBIDDEN_DISPLAY_NAME_CHARACTERS from
|
||||
// src/shared/protocol.ts.
|
||||
var forbiddenDisplayNameCharacters = regexp.MustCompile(
|
||||
`[\p{Cc}\p{Zl}\p{Zp}\x{061c}\x{200b}\x{200e}\x{200f}\x{202a}-\x{202e}` +
|
||||
`\x{2060}\x{2066}-\x{2069}\x{feff}]`)
|
||||
|
||||
// NormalizeDisplayName ports normalizeDisplayName. The second result is false
|
||||
// where the TypeScript returns null.
|
||||
func NormalizeDisplayName(value string) (string, bool) {
|
||||
if forbiddenDisplayNameCharacters.MatchString(value) {
|
||||
return "", false
|
||||
}
|
||||
// The TypeScript rejects code points in D800..DFFF, which a JavaScript
|
||||
// string can hold as an unpaired surrogate. A Go string can only carry one
|
||||
// as invalid UTF-8 (WTF-8), so utf8.ValidString is the exact analogue.
|
||||
if !utf8.ValidString(value) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalized := collapseUnicodeSpaces(
|
||||
strings.TrimFunc(norm.NFC.String(value), isUnicodeSpaceSeparator))
|
||||
count := utf8.RuneCountInString(normalized)
|
||||
if count < 1 || count > MaxDisplayNameCodePoints {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
// isUnicodeSpaceSeparator matches \p{Zs}. Every other character trimmed by
|
||||
// JavaScript's String.prototype.trim (Cc, Zl, Zp, FEFF) is already forbidden
|
||||
// above, so trimming Zs alone is equivalent here.
|
||||
func isUnicodeSpaceSeparator(character rune) bool {
|
||||
return unicode.Is(unicode.Zs, character)
|
||||
}
|
||||
|
||||
// collapseUnicodeSpaces implements .replace(/\p{Zs}+/gu, " ").
|
||||
func collapseUnicodeSpaces(value string) string {
|
||||
var builder strings.Builder
|
||||
pendingSpace := false
|
||||
for _, character := range value {
|
||||
if isUnicodeSpaceSeparator(character) {
|
||||
pendingSpace = true
|
||||
continue
|
||||
}
|
||||
if pendingSpace {
|
||||
builder.WriteByte(' ')
|
||||
pendingSpace = false
|
||||
}
|
||||
builder.WriteRune(character)
|
||||
}
|
||||
if pendingSpace {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// DisplayName mirrors displayNameSchema: 1..96 UTF-16 code units that
|
||||
// normalizeDisplayName leaves unchanged.
|
||||
type DisplayName string
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *DisplayName) UnmarshalJSON(data []byte) error {
|
||||
// encoding/json folds an unpaired \uD800-\uDFFF escape to U+FFFD, so the
|
||||
// raw token is the only place the TypeScript surrogate rejection is still
|
||||
// observable.
|
||||
if hasUnpairedSurrogateEscape(data) {
|
||||
return errors.New("displayName contains an unpaired surrogate")
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ValidDisplayName(value) {
|
||||
return errors.New("display name must be canonical")
|
||||
}
|
||||
*d = DisplayName(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidDisplayName mirrors displayNameSchema's bounds and canonical refine.
|
||||
func ValidDisplayName(value string) bool {
|
||||
length := UTF16Length(value)
|
||||
if length < 1 || length > MaxDisplayNameCodePoints*4 {
|
||||
return false
|
||||
}
|
||||
normalized, ok := NormalizeDisplayName(value)
|
||||
return ok && normalized == value
|
||||
}
|
||||
|
||||
func hasUnpairedSurrogateEscape(token []byte) bool {
|
||||
text := string(token)
|
||||
for index := 0; index+6 <= len(text); index++ {
|
||||
if text[index] != '\\' || text[index+1] != 'u' {
|
||||
continue
|
||||
}
|
||||
unit, err := strconv.ParseUint(text[index+2:index+6], 16, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case unit >= 0xd800 && unit <= 0xdbff:
|
||||
if index+12 > len(text) || text[index+6] != '\\' || text[index+7] != 'u' {
|
||||
return true
|
||||
}
|
||||
low, err := strconv.ParseUint(text[index+8:index+12], 16, 32)
|
||||
if err != nil || low < 0xdc00 || low > 0xdfff {
|
||||
return true
|
||||
}
|
||||
index += 11
|
||||
case unit >= 0xdc00 && unit <= 0xdfff:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidStunURL ports isValidStunUrl from src/shared/protocol.ts. The
|
||||
// TypeScript feeds "http://<authority>" to the WHATWG URL parser; net/url is
|
||||
// more permissive in several places, so the extra checks below restore the
|
||||
// WHATWG answers (see stun_test.go for the recorded comparisons).
|
||||
func ValidStunURL(value string) bool {
|
||||
separator := strings.IndexByte(value, ':')
|
||||
if separator <= 0 || !asciiEqualFold(value[:separator], "stun") {
|
||||
return false
|
||||
}
|
||||
|
||||
authority := value[separator+1:]
|
||||
if authority == "" || strings.HasSuffix(authority, ":") {
|
||||
return false
|
||||
}
|
||||
for _, character := range authority {
|
||||
if character == '\\' || character == '/' || character == '?' ||
|
||||
character == '#' || IsJSWhitespace(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse("http://" + authority)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// WHATWG reports an empty username/password for "@host" and ":@host";
|
||||
// net/url reports a non-nil Userinfo, so compare the parts, not the pointer.
|
||||
if parsed.User != nil {
|
||||
if parsed.User.Username() != "" {
|
||||
return false
|
||||
}
|
||||
if password, _ := parsed.User.Password(); password != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if hostname := parsed.Hostname(); hostname == "" || !validWHATWGHost(parsed.Host, hostname) {
|
||||
return false
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
// net/url accepts any digit run; WHATWG fails above 65535.
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number <= 0 || number > 65535 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// net/url leaves Path empty where WHATWG reports "/". The authority scan
|
||||
// above already rejected "/", "?" and "#", so these are belt and braces.
|
||||
return (parsed.Path == "" || parsed.Path == "/") &&
|
||||
parsed.RawQuery == "" && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
func asciiEqualFold(left, right string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(left); index++ {
|
||||
if asciiLower(left[index]) != asciiLower(right[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func asciiLower(character byte) byte {
|
||||
if character >= 'A' && character <= 'Z' {
|
||||
return character + 'a' - 'A'
|
||||
}
|
||||
return character
|
||||
}
|
||||
|
||||
// forbiddenDomainCodePoints is the WHATWG "forbidden domain code point" set
|
||||
// minus the characters the authority scan already rejected.
|
||||
const forbiddenDomainCodePoints = " #/:<>?@[\\]^|%"
|
||||
|
||||
func validWHATWGHost(rawHost, hostname string) bool {
|
||||
if strings.HasPrefix(rawHost, "[") {
|
||||
// net/url accepts "[1.2.3.4]" and IPv6 zone identifiers; the WHATWG
|
||||
// IPv6 parser accepts neither.
|
||||
return strings.Contains(hostname, ":") && !strings.Contains(hostname, "%")
|
||||
}
|
||||
for _, character := range hostname {
|
||||
if character <= 0x1f || character == 0x7f ||
|
||||
strings.ContainsRune(forbiddenDomainCodePoints, character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if hostEndsInNumber(hostname) {
|
||||
return validIPv4Host(hostname)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hostEndsInNumber implements the WHATWG "ends in a number" checker.
|
||||
func hostEndsInNumber(host string) bool {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) > 1 && parts[len(parts)-1] == "" {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
if last != "" && isASCIIDigits(last) {
|
||||
return true
|
||||
}
|
||||
_, ok := parseIPv4Number(last)
|
||||
return ok
|
||||
}
|
||||
|
||||
// validIPv4Host implements the WHATWG IPv4 parser's failure conditions.
|
||||
func validIPv4Host(host string) bool {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) > 1 && parts[len(parts)-1] == "" {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
if len(parts) > 4 {
|
||||
return false
|
||||
}
|
||||
numbers := make([]uint64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
number, ok := parseIPv4Number(part)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
for _, number := range numbers[:len(numbers)-1] {
|
||||
if number > 255 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
limit := uint64(1)
|
||||
for index := 0; index < 5-len(numbers); index++ {
|
||||
limit *= 256
|
||||
}
|
||||
return numbers[len(numbers)-1] < limit
|
||||
}
|
||||
|
||||
// parseIPv4Number implements the WHATWG IPv4 number parser (decimal, 0-octal
|
||||
// and 0x-hexadecimal forms).
|
||||
func parseIPv4Number(input string) (uint64, bool) {
|
||||
if input == "" {
|
||||
return 0, false
|
||||
}
|
||||
radix := 10
|
||||
switch {
|
||||
case len(input) >= 2 && (strings.HasPrefix(input, "0x") || strings.HasPrefix(input, "0X")):
|
||||
input, radix = input[2:], 16
|
||||
case len(input) >= 2 && input[0] == '0':
|
||||
input, radix = input[1:], 8
|
||||
}
|
||||
if input == "" {
|
||||
return 0, true
|
||||
}
|
||||
for index := 0; index < len(input); index++ {
|
||||
if radixDigit(input[index]) >= radix {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
number, err := strconv.ParseUint(input, radix, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
func radixDigit(character byte) int {
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
return int(character - '0')
|
||||
case character >= 'a' && character <= 'f':
|
||||
return int(character-'a') + 10
|
||||
case character >= 'A' && character <= 'F':
|
||||
return int(character-'A') + 10
|
||||
}
|
||||
return 99
|
||||
}
|
||||
|
||||
func isASCIIDigits(value string) bool {
|
||||
for index := 0; index < len(value); index++ {
|
||||
if value[index] < '0' || value[index] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUTF16Len(t *testing.T) {
|
||||
// zod's string bounds count UTF-16 code units, so an astral code point
|
||||
// counts as two.
|
||||
for _, sample := range []struct {
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"abc", 3},
|
||||
{"名", 1},
|
||||
{"\U0001f469", 2},
|
||||
{"\U0001f469\u200d\U0001f4bb", 5},
|
||||
} {
|
||||
if got := UTF16Length(sample.value); got != sample.want {
|
||||
t.Errorf("UTF16Length(%q) = %d, want %d", sample.value, got, sample.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDisplayName(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"composes and collapses", " Café 朋友 ",
|
||||
"Café 朋友", true},
|
||||
{"keeps a zero width joiner sequence",
|
||||
"玩家 \U0001f469\u200d\U0001f4bb",
|
||||
"玩家 \U0001f469\u200d\U0001f4bb", true},
|
||||
{"accepts the code point limit",
|
||||
repeat("名", MaxDisplayNameCodePoints),
|
||||
repeat("名", MaxDisplayNameCodePoints), true},
|
||||
{"rejects one code point past the limit",
|
||||
repeat("名", MaxDisplayNameCodePoints+1), "", false},
|
||||
{"rejects a control character", "a\nb", "", false},
|
||||
{"rejects a bidi override", "a\u202eb", "", false},
|
||||
{"rejects a byte order mark", "a\ufeffb", "", false},
|
||||
{"rejects a zero width space", "a\u200bb", "", false},
|
||||
{"rejects an unpaired surrogate", "a\xed\xa0\x80b", "", false},
|
||||
{"rejects an all-space name", " ", "", false},
|
||||
} {
|
||||
t.Run(sample.name, func(t *testing.T) {
|
||||
got, ok := NormalizeDisplayName(sample.value)
|
||||
if ok != sample.ok || got != sample.want {
|
||||
t.Fatalf("NormalizeDisplayName(%q) = (%q, %v), want (%q, %v)",
|
||||
sample.value, got, ok, sample.want, sample.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDisplayNameRequiresCanonicalForm(t *testing.T) {
|
||||
if ValidDisplayName(" Café ") {
|
||||
t.Error("a non-canonical display name must not validate")
|
||||
}
|
||||
if !ValidDisplayName("Café 朋友") {
|
||||
t.Error("a canonical display name must validate")
|
||||
}
|
||||
if ValidDisplayName("") {
|
||||
t.Error("an empty display name must not validate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayNameRejectsSurrogateEscapes(t *testing.T) {
|
||||
// encoding/json folds an unpaired escape to U+FFFD, so the raw token is the
|
||||
// only place the TypeScript surrogate rejection stays observable.
|
||||
var name DisplayName
|
||||
if err := name.UnmarshalJSON([]byte(`"a\ud800b"`)); err == nil {
|
||||
t.Fatal("an unpaired high surrogate escape must be rejected")
|
||||
}
|
||||
if err := name.UnmarshalJSON([]byte(`"a\udc00b"`)); err == nil {
|
||||
t.Fatal("an unpaired low surrogate escape must be rejected")
|
||||
}
|
||||
if err := name.UnmarshalJSON([]byte(`"a😀b"`)); err != nil {
|
||||
t.Fatalf("a surrogate pair escape must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarValidators(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
name string
|
||||
valid func(string) bool
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"opaque id at floor", ValidOpaqueID, "abcdefgh", true},
|
||||
{"opaque id below floor", ValidOpaqueID, "abcdefg", false},
|
||||
{"opaque id with punctuation", ValidOpaqueID, "abcdefg.", false},
|
||||
{"opaque id at ceiling", ValidOpaqueID, repeat("a", 128), true},
|
||||
{"opaque id past ceiling", ValidOpaqueID, repeat("a", 129), false},
|
||||
{"token at floor", ValidToken, repeat("a", 32), true},
|
||||
{"token below floor", ValidToken, repeat("a", 31), false},
|
||||
{"token past ceiling", ValidToken, repeat("a", 129), false},
|
||||
{"room code", ValidRoomCode, "1234", true},
|
||||
{"room code with leading zero", ValidRoomCode, "0123", false},
|
||||
{"room code too long", ValidRoomCode, "12345", false},
|
||||
{"room code too short", ValidRoomCode, "123", false},
|
||||
{"viewer grant", ValidViewerGrant, repeat("b", 21) + "g", true},
|
||||
{"viewer grant short", ValidViewerGrant, repeat("b", 21), false},
|
||||
{"viewer grant long", ValidViewerGrant, repeat("b", 23), false},
|
||||
{"viewer grant padded", ValidViewerGrant, repeat("b", 21) + "=", false},
|
||||
{"viewer grant off alphabet tail", ValidViewerGrant, repeat("b", 21) + "b", false},
|
||||
{"viewer password minimal", ValidViewerPassword, "x", true},
|
||||
{"viewer password simple", ValidViewerPassword, "simple-password", true},
|
||||
{"viewer password empty", ValidViewerPassword, "", false},
|
||||
{"viewer password with space", ValidViewerPassword, "contains space", false},
|
||||
{"viewer password with newline", ValidViewerPassword, "line\nbreak", false},
|
||||
{"viewer password past ceiling", ValidViewerPassword,
|
||||
repeat("x", MaxViewerPasswordLength+1), false},
|
||||
{"livekit url", ValidLiveKitWebSocketURL, "wss://sfu.example.test", true},
|
||||
{"livekit url plain ws", ValidLiveKitWebSocketURL, "ws://127.0.0.1:7880", true},
|
||||
{"livekit url https", ValidLiveKitWebSocketURL, "https://sfu.example.test", false},
|
||||
{"livekit url relative", ValidLiveKitWebSocketURL, "/signal", false},
|
||||
} {
|
||||
t.Run(sample.name, func(t *testing.T) {
|
||||
if got := sample.valid(sample.value); got != sample.want {
|
||||
t.Fatalf("validator(%q) = %v, want %v", sample.value, got, sample.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidStunURL records the WHATWG answers isValidStunUrl produces in the
|
||||
// browser. The cases marked "net/url divergence" are the ones where Go's
|
||||
// parser alone would disagree.
|
||||
func TestValidStunURL(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
// tests/server-config.test.ts and tests/protocol.test.ts.
|
||||
{"stun:stun.test:3478", true},
|
||||
{"stun:share.test:5349", true},
|
||||
{"STUN:[2001:db8::1]:3478", true},
|
||||
{"stun:stun.test", true},
|
||||
{"stun:stun.test/path", false},
|
||||
{"stun:stun.test?transport=udp", false},
|
||||
{"stun:stun.test#fragment", false},
|
||||
{"stun:", false},
|
||||
{"stun:stun.test:", false},
|
||||
{"stun:stun.test:0", false},
|
||||
{"stun:user@stun.test:3478", false},
|
||||
{"turn:turn.test:3478", false},
|
||||
{"stuns:stun.test:5349", false},
|
||||
{"stun:relay.example.test:3478?transport=udp", false},
|
||||
|
||||
// net/url divergence: it accepts ports above 65535.
|
||||
{"stun:stun.test:65535", true},
|
||||
{"stun:stun.test:65536", false},
|
||||
{"stun:stun.test:99999", false},
|
||||
// net/url divergence: it accepts "<", ">" and "]" in a host name.
|
||||
{"stun:a<b:3478", false},
|
||||
{"stun:a>b:3478", false},
|
||||
{"stun:a]b:3478", false},
|
||||
{"stun:a|b:3478", false},
|
||||
{"stun:a^b:3478", false},
|
||||
// net/url divergence: it does not run the WHATWG IPv4 parser.
|
||||
{"stun:999.999.999.999", false},
|
||||
{"stun:1.2.3.4.5", false},
|
||||
{"stun:09.1", false},
|
||||
{"stun:1.2.3.256", false},
|
||||
{"stun:1.2.3.4", true},
|
||||
{"stun:1.2.3.4.", true},
|
||||
{"stun:0300.0250.0.1", true},
|
||||
{"stun:0x7f.1", true},
|
||||
{"stun:123", true},
|
||||
// net/url divergence: it accepts "[1.2.3.4]" and IPv6 zone ids.
|
||||
{"stun:[1.2.3.4]", false},
|
||||
{"stun:[notanip]:3478", false},
|
||||
{"stun:[2001:db8::1", false},
|
||||
{"stun:[::1]", true},
|
||||
// WHATWG treats empty userinfo as absent.
|
||||
{"stun:@stun.test:3478", true},
|
||||
{"stun::@stun.test:3478", true},
|
||||
{"stun::pass@stun.test:3478", false},
|
||||
{"stun:user:@stun.test:3478", false},
|
||||
// A host is mandatory and the authority scan rejects the framing
|
||||
// characters before net/url ever sees them.
|
||||
{"stun::3478", false},
|
||||
{"stun:stun.test:+3478", false},
|
||||
{"stun:stun.test:34a78", false},
|
||||
{"stun:stun test:3478", false},
|
||||
{"stun:stun.test\\path", false},
|
||||
{"stun:a_b.test", true},
|
||||
{"stun:stun.test:03478", true},
|
||||
} {
|
||||
t.Run(sample.value, func(t *testing.T) {
|
||||
if got := ValidStunURL(sample.value); got != sample.want {
|
||||
t.Fatalf("ValidStunURL(%q) = %v, want %v", sample.value, got, sample.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func repeat(value string, count int) string {
|
||||
out := make([]byte, 0, len(value)*count)
|
||||
for index := 0; index < count; index++ {
|
||||
out = append(out, value...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ServerMessage is one member of serverMessageSchema. The interface is sealed:
|
||||
// only the message structs in this file implement it.
|
||||
type ServerMessage interface {
|
||||
isServerMessage()
|
||||
}
|
||||
|
||||
// AuthenticatedHostMessage is the host member of authenticatedMessageSchema.
|
||||
// The field order is the emission order of the object literal in
|
||||
// src/server/signaling.ts (`role` and `viewerPasswordEnabled` are appended
|
||||
// after the shared base spread).
|
||||
type AuthenticatedHostMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
PeerID string `json:"peerId"`
|
||||
RoomExpiresAt *string `json:"roomExpiresAt"`
|
||||
MaxViewers Int `json:"maxViewers"`
|
||||
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
|
||||
HostOnline bool `json:"hostOnline"`
|
||||
HostPaused *bool `json:"hostPaused,omitempty"`
|
||||
ConnectionID *string `json:"connectionId"`
|
||||
IceConfig IceConfig `json:"iceConfig"`
|
||||
RoutePolicy RoutePolicy `json:"routePolicy"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
|
||||
MediaMode string `json:"mediaMode"`
|
||||
ShareGeneration *string `json:"shareGeneration"`
|
||||
RouteRevision Int `json:"routeRevision"`
|
||||
RouteAssignment ParticipantRouteAssignment `json:"routeAssignment"`
|
||||
QualitySettings QualitySettings `json:"qualitySettings"`
|
||||
SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"`
|
||||
Role Role `json:"role"`
|
||||
ViewerPasswordEnabled bool `json:"viewerPasswordEnabled"`
|
||||
}
|
||||
|
||||
// AuthenticatedViewerMessage is the viewer member of
|
||||
// authenticatedMessageSchema.
|
||||
type AuthenticatedViewerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
PeerID string `json:"peerId"`
|
||||
RoomExpiresAt *string `json:"roomExpiresAt"`
|
||||
MaxViewers Int `json:"maxViewers"`
|
||||
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
|
||||
HostOnline bool `json:"hostOnline"`
|
||||
HostPaused *bool `json:"hostPaused,omitempty"`
|
||||
ConnectionID *string `json:"connectionId"`
|
||||
IceConfig IceConfig `json:"iceConfig"`
|
||||
RoutePolicy RoutePolicy `json:"routePolicy"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
|
||||
MediaMode string `json:"mediaMode"`
|
||||
ShareGeneration *string `json:"shareGeneration"`
|
||||
RouteRevision Int `json:"routeRevision"`
|
||||
RouteAssignment ParticipantRouteAssignment `json:"routeAssignment"`
|
||||
QualitySettings QualitySettings `json:"qualitySettings"`
|
||||
SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// SignalingChallengeResponseMessage is the challenge echo.
|
||||
type SignalingChallengeResponseMessage struct {
|
||||
Type string `json:"type"`
|
||||
Sequence Int `json:"sequence"`
|
||||
}
|
||||
|
||||
// ServerSignalMessage is the forwarded "signal" message.
|
||||
type ServerSignalMessage struct {
|
||||
Type string `json:"type"`
|
||||
FromPeerID string `json:"fromPeerId"`
|
||||
Payload SignalPayload `json:"payload"`
|
||||
}
|
||||
|
||||
// ServerRestartRequestMessage is the forwarded "restart-request" message.
|
||||
type ServerRestartRequestMessage struct {
|
||||
Type string `json:"type"`
|
||||
FromPeerID string `json:"fromPeerId"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Rebuild bool `json:"rebuild"`
|
||||
}
|
||||
|
||||
// RouteUpdatePrepareMessage is the phase "prepare" member of route-update.
|
||||
type RouteUpdatePrepareMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
Phase string `json:"phase"`
|
||||
Assignment ParticipantRouteAssignment `json:"assignment"`
|
||||
Candidate PreparedRouteCandidate `json:"candidate"`
|
||||
}
|
||||
|
||||
// RouteUpdateActiveMessage is the phase "active" member of route-update.
|
||||
type RouteUpdateActiveMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
Phase string `json:"phase"`
|
||||
Assignment ParticipantRouteAssignment `json:"assignment"`
|
||||
}
|
||||
|
||||
// RouteStatusMessage is the crossed (state, reason) union: "waiting" pairs
|
||||
// with "sfu-admission" and "failed" pairs with "route-exhausted".
|
||||
type RouteStatusMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RouteDiagnosticSnapshotMessage carries the privacy-safe route snapshot.
|
||||
type RouteDiagnosticSnapshotMessage struct {
|
||||
Type string `json:"type"`
|
||||
Snapshot RouteDiagnosticSnapshot `json:"snapshot"`
|
||||
}
|
||||
|
||||
// SfuConfigMessage carries a LiveKit URL and a short-lived token.
|
||||
type SfuConfigMessage struct {
|
||||
Type string `json:"type"`
|
||||
Revision Int `json:"revision"`
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// QualitySettingsMessage broadcasts the host's quality settings.
|
||||
type QualitySettingsMessage struct {
|
||||
Type string `json:"type"`
|
||||
QualitySettings QualitySettings `json:"qualitySettings"`
|
||||
}
|
||||
|
||||
// RoutePolicyMessage broadcasts the host's route policy.
|
||||
type RoutePolicyMessage struct {
|
||||
Type string `json:"type"`
|
||||
ShareGeneration string `json:"shareGeneration"`
|
||||
RoutePolicy RoutePolicy `json:"routePolicy"`
|
||||
SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"`
|
||||
}
|
||||
|
||||
// PauseSharingSourceMessage asks the host to pause its capture source.
|
||||
type PauseSharingSourceMessage struct {
|
||||
Type string `json:"type"`
|
||||
ShareGeneration string `json:"shareGeneration"`
|
||||
}
|
||||
|
||||
// ServerViewerQualityEvidenceMessage is the forwarded evidence envelope.
|
||||
type ServerViewerQualityEvidenceMessage struct {
|
||||
Type string `json:"type"`
|
||||
ViewerPeerID string `json:"viewerPeerId"`
|
||||
Upstream MediaRouteUpstream `json:"upstream"`
|
||||
Guard ViewerQualityEvidenceGuard `json:"guard"`
|
||||
Sequence Int `json:"sequence"`
|
||||
WindowMs Int `json:"windowMs"`
|
||||
Metrics ViewerQualityEvidenceMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
// HostStatusMessage reports host presence and pause state.
|
||||
type HostStatusMessage struct {
|
||||
Type string `json:"type"`
|
||||
Online bool `json:"online"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
// ViewerPresenceMessage carries the ordered participant roster.
|
||||
type ViewerPresenceMessage struct {
|
||||
Type string `json:"type"`
|
||||
Viewers []ParticipantPresenceEntry `json:"viewers"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (v ViewerPresenceMessage) MarshalJSON() ([]byte, error) {
|
||||
type raw ViewerPresenceMessage
|
||||
value := raw(v)
|
||||
if value.Viewers == nil {
|
||||
value.Viewers = []ParticipantPresenceEntry{}
|
||||
}
|
||||
return marshalJSON(value)
|
||||
}
|
||||
|
||||
// ViewerGrantRevokedMessage announces a superseded viewer authorization.
|
||||
type ViewerGrantRevokedMessage struct {
|
||||
Type string `json:"type"`
|
||||
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
|
||||
}
|
||||
|
||||
// SharingStoppedMessage is { type: "sharing-stopped" }.
|
||||
type SharingStoppedMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RoomClosedMessage is { type: "room-closed", reason }.
|
||||
type RoomClosedMessage struct {
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ErrorMessage is { type: "error", code, message }.
|
||||
type ErrorMessage struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (AuthenticatedHostMessage) isServerMessage() {}
|
||||
func (AuthenticatedViewerMessage) isServerMessage() {}
|
||||
func (SignalingChallengeResponseMessage) isServerMessage() {}
|
||||
func (ServerSignalMessage) isServerMessage() {}
|
||||
func (ServerRestartRequestMessage) isServerMessage() {}
|
||||
func (RouteUpdatePrepareMessage) isServerMessage() {}
|
||||
func (RouteUpdateActiveMessage) isServerMessage() {}
|
||||
func (RouteStatusMessage) isServerMessage() {}
|
||||
func (RouteDiagnosticSnapshotMessage) isServerMessage() {}
|
||||
func (SfuConfigMessage) isServerMessage() {}
|
||||
func (QualitySettingsMessage) isServerMessage() {}
|
||||
func (RoutePolicyMessage) isServerMessage() {}
|
||||
func (PauseSharingSourceMessage) isServerMessage() {}
|
||||
func (ServerViewerQualityEvidenceMessage) isServerMessage() {}
|
||||
func (HostStatusMessage) isServerMessage() {}
|
||||
func (ViewerPresenceMessage) isServerMessage() {}
|
||||
func (ViewerGrantRevokedMessage) isServerMessage() {}
|
||||
func (SharingStoppedMessage) isServerMessage() {}
|
||||
func (RoomClosedMessage) isServerMessage() {}
|
||||
func (ErrorMessage) isServerMessage() {}
|
||||
|
||||
// EncodeServerMessage serializes one server message with the key order of the
|
||||
// TypeScript object literal that produces it.
|
||||
func EncodeServerMessage(message ServerMessage) ([]byte, error) {
|
||||
return marshalJSON(message)
|
||||
}
|
||||
|
||||
// DecodeServerMessage ports decodeServerMessage. The server never reads its
|
||||
// own messages in production; this exists so the shared wire fixture can be
|
||||
// replayed from Go.
|
||||
func DecodeServerMessage(data []byte) (ServerMessage, error) {
|
||||
messageType, err := typeOf(data, "type")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch messageType {
|
||||
case "authenticated":
|
||||
return decodeAuthenticated(data)
|
||||
case "signaling-challenge-response":
|
||||
return decodeChallengeResponse(data)
|
||||
case "signal":
|
||||
return decodeServerSignal(data)
|
||||
case "restart-request":
|
||||
return decodeServerRestartRequest(data)
|
||||
case "route-update":
|
||||
return decodeRouteUpdate(data)
|
||||
case "route-status":
|
||||
return decodeRouteStatus(data)
|
||||
case "route-diagnostic-snapshot":
|
||||
return decodeRouteDiagnosticSnapshotMessage(data)
|
||||
case "sfu-config":
|
||||
return decodeSfuConfig(data)
|
||||
case "quality-settings":
|
||||
return decodeQualitySettingsMessage(data)
|
||||
case "route-policy":
|
||||
return decodeRoutePolicyMessage(data)
|
||||
case "pause-sharing-source":
|
||||
return decodePauseSharingSource(data)
|
||||
case "viewer-quality-evidence":
|
||||
return decodeServerViewerQualityEvidence(data)
|
||||
case "host-status":
|
||||
return decodeHostStatus(data)
|
||||
case "viewer-presence":
|
||||
return decodeViewerPresence(data)
|
||||
case "viewer-grant-revoked":
|
||||
return decodeViewerGrantRevoked(data)
|
||||
case "sharing-stopped":
|
||||
return decodeEmptyServerMessage(data, SharingStoppedMessage{Type: messageType})
|
||||
case "room-closed":
|
||||
return decodeRoomClosed(data)
|
||||
case "error":
|
||||
return decodeErrorMessage(data)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown server message type %q", messageType)
|
||||
}
|
||||
|
||||
func decodeEmptyServerMessage(data []byte, message ServerMessage) (ServerMessage, error) {
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
present, err := decodeObject(data, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func validateAuthenticatedBase(
|
||||
present fields,
|
||||
protocol string, peerID string, roomExpiresAt *string,
|
||||
maxViewers, endpointCapacity Int, connectionID *string,
|
||||
codeEntryPolicy, viewerAuthorizationGeneration, mediaMode string,
|
||||
shareGeneration *string, routeRevision Int, sfuStandbyURL *string,
|
||||
) error {
|
||||
if err := present.require("type", "protocol", "peerId", "maxViewers",
|
||||
"endpointMediaCopyCapacity", "hostOnline", "iceConfig", "codeEntryPolicy",
|
||||
"viewerAuthorizationGeneration", "mediaMode", "routeRevision",
|
||||
"routeAssignment", "qualitySettings", "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := present.requireNullable(
|
||||
"roomExpiresAt", "connectionId", "shareGeneration"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := present.optional("hostPaused", "routePolicy", "sfuStandbyUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
if protocol != SignalingProtocol {
|
||||
return errors.New("protocol is not the current signaling generation")
|
||||
}
|
||||
if !ValidOpaqueID(peerID) {
|
||||
return errors.New("peerId is not an opaque id")
|
||||
}
|
||||
if roomExpiresAt != nil && !ValidISODateTime(*roomExpiresAt) {
|
||||
return errors.New("roomExpiresAt is not an ISO instant")
|
||||
}
|
||||
if !inRangeInt(maxViewers, 1, MaxViewersPerRoomLimit) {
|
||||
return errors.New("maxViewers is out of range")
|
||||
}
|
||||
if !inRangeInt(endpointCapacity, 1, MaxEndpointMediaCopyCapacity) {
|
||||
return errors.New("endpointMediaCopyCapacity is out of range")
|
||||
}
|
||||
if connectionID != nil && !ValidOpaqueID(*connectionID) {
|
||||
return errors.New("connectionId is not an opaque id")
|
||||
}
|
||||
if !enumOf(codeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) {
|
||||
return fmt.Errorf("unknown codeEntryPolicy %q", codeEntryPolicy)
|
||||
}
|
||||
if !ValidOpaqueID(viewerAuthorizationGeneration) {
|
||||
return errors.New("viewerAuthorizationGeneration is not an opaque id")
|
||||
}
|
||||
if mediaMode != "peer-assisted" {
|
||||
return errors.New("mediaMode must be peer-assisted")
|
||||
}
|
||||
if shareGeneration != nil && !ValidOpaqueID(*shareGeneration) {
|
||||
return errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
if !validRevision(routeRevision) {
|
||||
return errors.New("routeRevision is out of range")
|
||||
}
|
||||
if sfuStandbyURL != nil && !ValidLiveKitWebSocketURL(*sfuStandbyURL) {
|
||||
return errors.New("sfuStandbyUrl is not a LiveKit WebSocket URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeAuthenticated(data []byte) (ServerMessage, error) {
|
||||
role, err := typeOf(data, "role")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch role {
|
||||
case RoleHost:
|
||||
var message AuthenticatedHostMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("viewerPasswordEnabled"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
|
||||
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.ConnectionID, message.CodeEntryPolicy,
|
||||
message.ViewerAuthorizationGeneration, message.MediaMode,
|
||||
message.ShareGeneration, message.RouteRevision, message.SfuStandbyURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !present.has("routePolicy") {
|
||||
message.RoutePolicy = DefaultRoutePolicy
|
||||
}
|
||||
return message, nil
|
||||
case RoleViewer:
|
||||
var message AuthenticatedViewerMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
|
||||
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.ConnectionID, message.CodeEntryPolicy,
|
||||
message.ViewerAuthorizationGeneration, message.MediaMode,
|
||||
message.ShareGeneration, message.RouteRevision, message.SfuStandbyURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !present.has("routePolicy") {
|
||||
message.RoutePolicy = DefaultRoutePolicy
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown authenticated role %q", role)
|
||||
}
|
||||
|
||||
func decodeChallengeResponse(data []byte) (ServerMessage, error) {
|
||||
var message SignalingChallengeResponseMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "sequence"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inRangeInt(message.Sequence, 0, MaxSafeInteger) {
|
||||
return nil, errors.New("sequence is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeServerSignal(data []byte) (ServerMessage, error) {
|
||||
var message ServerSignalMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "fromPeerId", "payload"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.FromPeerID) {
|
||||
return nil, errors.New("fromPeerId is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeServerRestartRequest(data []byte) (ServerMessage, error) {
|
||||
var message ServerRestartRequestMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "fromPeerId", "connectionId", "rebuild"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.FromPeerID) || !ValidOpaqueID(message.ConnectionID) {
|
||||
return nil, errors.New("restart-request ids are not opaque ids")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteUpdate(data []byte) (ServerMessage, error) {
|
||||
phase, err := typeOf(data, "phase")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch phase {
|
||||
case "prepare":
|
||||
var message RouteUpdatePrepareMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require(
|
||||
"type", "revision", "phase", "assignment", "candidate"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
return message, nil
|
||||
case "active":
|
||||
var message RouteUpdateActiveMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "phase", "assignment"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown route-update phase %q", phase)
|
||||
}
|
||||
|
||||
func decodeRouteStatus(data []byte) (ServerMessage, error) {
|
||||
var message RouteStatusMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "state", "reason"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
switch {
|
||||
case message.State == "waiting" && message.Reason == "sfu-admission":
|
||||
case message.State == "failed" && message.Reason == "route-exhausted":
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"route-status pairs state %q with reason %q", message.State, message.Reason)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRouteDiagnosticSnapshotMessage(data []byte) (ServerMessage, error) {
|
||||
var message RouteDiagnosticSnapshotMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "snapshot"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeSfuConfig(data []byte) (ServerMessage, error) {
|
||||
var message SfuConfigMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "revision", "url", "token"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validRevision(message.Revision) {
|
||||
return nil, errors.New("revision is out of range")
|
||||
}
|
||||
if !ValidLiveKitWebSocketURL(message.URL) {
|
||||
return nil, errors.New("url is not a LiveKit WebSocket URL")
|
||||
}
|
||||
if length := UTF16Length(message.Token); length < 1 || length > MaxSfuTokenLength {
|
||||
return nil, errors.New("token length is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeQualitySettingsMessage(data []byte) (ServerMessage, error) {
|
||||
var message QualitySettingsMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "qualitySettings"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRoutePolicyMessage(data []byte) (ServerMessage, error) {
|
||||
var message RoutePolicyMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "shareGeneration", "routePolicy"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.optional("sfuStandbyUrl"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ShareGeneration) {
|
||||
return nil, errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
if message.SfuStandbyURL != nil && !ValidLiveKitWebSocketURL(*message.SfuStandbyURL) {
|
||||
return nil, errors.New("sfuStandbyUrl is not a LiveKit WebSocket URL")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodePauseSharingSource(data []byte) (ServerMessage, error) {
|
||||
var message PauseSharingSourceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "shareGeneration"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ShareGeneration) {
|
||||
return nil, errors.New("shareGeneration is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeServerViewerQualityEvidence(data []byte) (ServerMessage, error) {
|
||||
var message ServerViewerQualityEvidenceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "viewerPeerId", "upstream", "guard",
|
||||
"sequence", "windowMs", "metrics"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ViewerPeerID) {
|
||||
return nil, errors.New("viewerPeerId is not an opaque id")
|
||||
}
|
||||
// The forwarded envelope narrows the upstream to the active variants.
|
||||
if message.Upstream.Kind != "peer" && message.Upstream.Kind != "sfu" {
|
||||
return nil, errors.New("forwarded evidence upstream must be peer or sfu")
|
||||
}
|
||||
if err := validateEvidenceWindow(message.Sequence, message.WindowMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeHostStatus(data []byte) (ServerMessage, error) {
|
||||
var message HostStatusMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "online", "paused"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeViewerPresence(data []byte) (ServerMessage, error) {
|
||||
var message ViewerPresenceMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "viewers"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(message.Viewers) > MaxParticipantsPerRoomLimit {
|
||||
return nil, errors.New("viewer presence holds too many entries")
|
||||
}
|
||||
// superRefine: viewer presence peer IDs must be unique.
|
||||
seen := make(map[string]struct{}, len(message.Viewers))
|
||||
for _, viewer := range message.Viewers {
|
||||
if _, duplicate := seen[viewer.PeerID]; duplicate {
|
||||
return nil, errors.New("Viewer presence peer IDs must be unique")
|
||||
}
|
||||
seen[viewer.PeerID] = struct{}{}
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeViewerGrantRevoked(data []byte) (ServerMessage, error) {
|
||||
var message ViewerGrantRevokedMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "viewerAuthorizationGeneration"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidOpaqueID(message.ViewerAuthorizationGeneration) {
|
||||
return nil, errors.New("viewerAuthorizationGeneration is not an opaque id")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeRoomClosed(data []byte) (ServerMessage, error) {
|
||||
var message RoomClosedMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "reason"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enumOf(message.Reason, "host-ended", "expired") {
|
||||
return nil, fmt.Errorf("unknown room-closed reason %q", message.Reason)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeErrorMessage(data []byte) (ServerMessage, error) {
|
||||
var message ErrorMessage
|
||||
present, err := decodeObject(data, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := present.require("type", "code", "message"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enumOf(message.Code, errorCodes...) {
|
||||
return nil, fmt.Errorf("unknown error code %q", message.Code)
|
||||
}
|
||||
if length := UTF16Length(message.Message); length < 1 || length > 256 {
|
||||
return nil, errors.New("error message length is out of range")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// testdata/node-rooms.db was written once by node:sqlite (DatabaseSync) with
|
||||
// the schema of src/server/room-database.ts and two synthetic rooms: 4321 is
|
||||
// active (NULL lease) with a 48-byte password material, 5678 is dormant with a
|
||||
// lease of 1000 ms. No real token, grant or password is involved. The extension
|
||||
// is .db because .gitignore excludes *.sqlite repository-wide.
|
||||
func copyNodeFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
fixture, err := os.ReadFile(filepath.Join("testdata", "node-rooms.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "rooms.sqlite")
|
||||
if err := os.WriteFile(path, fixture, 0o600); err != nil {
|
||||
t.Fatalf("write fixture copy: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func openFixture(t *testing.T, startupNowMs, activeLeaseExpiresAtMs int64) []StoredRoomAuthority {
|
||||
t.Helper()
|
||||
database, err := NewDatabase(copyNodeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
restored, err := database.Initialize(startupNowMs, activeLeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func TestRecoversANodeWrittenDatabase(t *testing.T) {
|
||||
restored := openFixture(t, 5_000, 6_000)
|
||||
if len(restored) != 1 {
|
||||
t.Fatalf("restored %d rooms, want 1 (the dormant room was due at 1000)", len(restored))
|
||||
}
|
||||
room := restored[0]
|
||||
if room.RoomID != "4321" || room.CodeEntryPolicy != protocol.CodeEntryPrivate {
|
||||
t.Fatalf("restored %+v", room)
|
||||
}
|
||||
if room.LeaseExpiresAtMs == nil || *room.LeaseExpiresAtMs != 6_000 {
|
||||
t.Fatalf("active room lease = %v, want the startup lease 6000", room.LeaseExpiresAtMs)
|
||||
}
|
||||
if !bytes.Equal(room.HostTokenDigest, bytes.Repeat([]byte{0x11}, 32)) ||
|
||||
!bytes.Equal(room.ViewerGrantDigest, bytes.Repeat([]byte{0x22}, 32)) {
|
||||
t.Fatal("digests did not survive the Node round trip")
|
||||
}
|
||||
if room.ViewerAuthorizationGeneration != "MzMzMzMzMzMzMzMzMzMzMw" {
|
||||
t.Fatalf("generation = %q", room.ViewerAuthorizationGeneration)
|
||||
}
|
||||
if !bytes.Equal(room.ViewerPasswordMaterial, bytes.Repeat([]byte{0x44}, 48)) {
|
||||
t.Fatalf("password material = %d bytes", len(room.ViewerPasswordMaterial))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepsADormantNodeWrittenDeadline(t *testing.T) {
|
||||
restored := openFixture(t, 500, 1_500)
|
||||
if len(restored) != 2 {
|
||||
t.Fatalf("restored %d rooms, want 2", len(restored))
|
||||
}
|
||||
// readStoredRooms orders by room_id.
|
||||
if restored[0].RoomID != "4321" || restored[0].LeaseExpiresAtMs == nil ||
|
||||
*restored[0].LeaseExpiresAtMs != 1_500 {
|
||||
t.Fatalf("active room = %+v", restored[0])
|
||||
}
|
||||
if restored[1].RoomID != "5678" || restored[1].LeaseExpiresAtMs == nil ||
|
||||
*restored[1].LeaseExpiresAtMs != 1_000 {
|
||||
t.Fatalf("dormant room = %+v", restored[1])
|
||||
}
|
||||
if restored[1].ViewerPasswordMaterial != nil {
|
||||
t.Fatalf("dormant room material = %v, want nil", restored[1].ViewerPasswordMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRestoresANodeWrittenDatabase(t *testing.T) {
|
||||
testClock := &clock{nowMs: 5_000}
|
||||
store := stableStore(t, copyNodeFixture(t), testClock, Options{LeaseMs: 1_000})
|
||||
if store.Size() != 1 {
|
||||
t.Fatalf("size = %d, want 1", store.Size())
|
||||
}
|
||||
// The recovered room holds the startup lease: 5000 + 1000.
|
||||
testClock.nowMs = 6_001
|
||||
expired, err := store.ExpireRooms(testClock.nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireRooms: %v", err)
|
||||
}
|
||||
if len(expired) != 1 || expired[0].RoomID != "4321" {
|
||||
t.Fatalf("expired = %+v", expired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaTextMatchesTheNodeFixture proves the CREATE TABLE text this package
|
||||
// executes is byte for byte the one src/server/room-database.ts executed.
|
||||
func TestSchemaTextMatchesTheNodeFixture(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
created := readSchemaText(t, path)
|
||||
fixture := readSchemaText(t, copyNodeFixture(t))
|
||||
if created.sql != fixture.sql {
|
||||
t.Fatalf("schema text differs\n go: %q\n node: %q", created.sql, fixture.sql)
|
||||
}
|
||||
if created.applicationID != fixture.applicationID ||
|
||||
created.applicationID != roomDatabaseApplicationID {
|
||||
t.Fatalf("application_id go=%d node=%d", created.applicationID, fixture.applicationID)
|
||||
}
|
||||
if created.userVersion != fixture.userVersion ||
|
||||
created.userVersion != roomDatabaseSchemaVersion {
|
||||
t.Fatalf("user_version go=%d node=%d", created.userVersion, fixture.userVersion)
|
||||
}
|
||||
}
|
||||
|
||||
type schemaText struct {
|
||||
sql string
|
||||
applicationID int64
|
||||
userVersion int64
|
||||
}
|
||||
|
||||
func readSchemaText(t *testing.T, path string) schemaText {
|
||||
t.Helper()
|
||||
raw := rawDatabase(t, path)
|
||||
var text schemaText
|
||||
if err := raw.QueryRow(
|
||||
"SELECT sql FROM sqlite_schema WHERE name = 'rooms'").Scan(&text.sql); err != nil {
|
||||
t.Fatalf("read schema: %v", err)
|
||||
}
|
||||
if err := raw.QueryRow("PRAGMA application_id").Scan(&text.applicationID); err != nil {
|
||||
t.Fatalf("read application_id: %v", err)
|
||||
}
|
||||
if err := raw.QueryRow("PRAGMA user_version").Scan(&text.userVersion); err != nil {
|
||||
t.Fatalf("read user_version: %v", err)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
// The pure-Go driver: the port must build with CGO_ENABLED=0 (D2).
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// roomDatabaseApplicationID and roomDatabaseSchemaVersion are the
|
||||
// APPLICATION_ID / SCHEMA_VERSION of src/server/room-database.ts, checked on
|
||||
// open so another SQLite file cannot be adopted as the room authority. The ID
|
||||
// is ASCII "SCRN"; roomSchema below repeats it in decimal because that literal
|
||||
// has to match the TypeScript statement text byte for byte.
|
||||
const (
|
||||
roomDatabaseApplicationID = 0x5343524e
|
||||
roomDatabaseSchemaVersion = 1
|
||||
)
|
||||
|
||||
// roomSchema is the exact text src/server/room-database.ts executes, so a file
|
||||
// created here and one created by the TypeScript hold the same sqlite_schema.
|
||||
const roomSchema = `
|
||||
CREATE TABLE rooms (
|
||||
room_id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (room_id GLOB '[1-9][0-9][0-9][0-9]'),
|
||||
host_token_digest BLOB NOT NULL
|
||||
CHECK (typeof(host_token_digest) = 'blob' AND length(host_token_digest) = 32),
|
||||
viewer_grant_digest BLOB NOT NULL
|
||||
CHECK (typeof(viewer_grant_digest) = 'blob' AND length(viewer_grant_digest) = 32),
|
||||
viewer_authorization_generation TEXT NOT NULL
|
||||
CHECK (
|
||||
length(viewer_authorization_generation) = 22 AND
|
||||
viewer_authorization_generation NOT GLOB '*[^A-Za-z0-9_-]*'
|
||||
),
|
||||
code_entry_policy TEXT NOT NULL
|
||||
CHECK (code_entry_policy IN ('open', 'private')),
|
||||
viewer_password_material BLOB NULL
|
||||
CHECK (
|
||||
viewer_password_material IS NULL OR
|
||||
(typeof(viewer_password_material) = 'blob' AND length(viewer_password_material) = 48)
|
||||
),
|
||||
lease_expires_at_ms INTEGER NULL
|
||||
CHECK (
|
||||
lease_expires_at_ms IS NULL OR
|
||||
(lease_expires_at_ms > 0 AND lease_expires_at_ms <= 9007199254740991)
|
||||
)
|
||||
) STRICT;
|
||||
PRAGMA application_id = 1396920910;
|
||||
PRAGMA user_version = 1;
|
||||
`
|
||||
|
||||
const selectStoredRooms = `SELECT
|
||||
room_id,
|
||||
host_token_digest,
|
||||
viewer_grant_digest,
|
||||
viewer_authorization_generation,
|
||||
code_entry_policy,
|
||||
viewer_password_material,
|
||||
lease_expires_at_ms
|
||||
FROM rooms
|
||||
ORDER BY room_id`
|
||||
|
||||
const insertStoredRoom = `INSERT INTO rooms (
|
||||
room_id,
|
||||
host_token_digest,
|
||||
viewer_grant_digest,
|
||||
viewer_authorization_generation,
|
||||
code_entry_policy,
|
||||
viewer_password_material,
|
||||
lease_expires_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const deleteRoomStatement = "DELETE FROM rooms WHERE room_id = ? AND host_token_digest = ?"
|
||||
|
||||
// StoredRoomAuthority is StoredRoomAuthority. A nil ViewerPasswordMaterial and
|
||||
// a nil LeaseExpiresAtMs are the TypeScript nulls.
|
||||
type StoredRoomAuthority struct {
|
||||
RoomID string
|
||||
HostTokenDigest []byte
|
||||
ViewerGrantDigest []byte
|
||||
ViewerAuthorizationGeneration string
|
||||
CodeEntryPolicy protocol.CodeEntryPolicy
|
||||
ViewerPasswordMaterial []byte
|
||||
LeaseExpiresAtMs *int64
|
||||
}
|
||||
|
||||
// RoomIdentity is the {roomId, hostTokenDigest} pair deleteRooms takes.
|
||||
type RoomIdentity struct {
|
||||
RoomID string
|
||||
HostTokenDigest []byte
|
||||
}
|
||||
|
||||
// Database is RoomDatabase: the SQLite stable authority. It holds one
|
||||
// connection for its lifetime, because `PRAGMA locking_mode = EXCLUSIVE` is a
|
||||
// connection property that a recycled pool connection would silently drop (D2).
|
||||
type Database struct {
|
||||
path string
|
||||
db *sql.DB
|
||||
conn *sql.Conn
|
||||
// inTransaction reproduces DatabaseSync.isTransaction, which the driver
|
||||
// does not expose; the ROLLBACK guard depends on it.
|
||||
inTransaction bool
|
||||
}
|
||||
|
||||
// NewDatabase is the RoomDatabase constructor.
|
||||
func NewDatabase(path string) (*Database, error) {
|
||||
if path == "" || path == ":memory:" {
|
||||
return nil, errors.New("Room database path must identify a file")
|
||||
}
|
||||
return &Database{path: path}, nil
|
||||
}
|
||||
|
||||
// Initialize opens the file, takes the exclusive lock, creates or verifies the
|
||||
// schema and recovers the stored rooms: rooms whose lease has passed are
|
||||
// deleted and rooms that were active at shutdown take activeLeaseExpiresAtMs.
|
||||
func (d *Database) Initialize(
|
||||
startupNowMs, activeLeaseExpiresAtMs int64,
|
||||
) (restored []StoredRoomAuthority, err error) {
|
||||
if d.conn != nil {
|
||||
return nil, errors.New("Room database is already initialized")
|
||||
}
|
||||
if err := assertTimestamp(startupNowMs, "Room database startup time", true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertTimestamp(
|
||||
activeLeaseExpiresAtMs, "Room database active-room lease deadline", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activeLeaseExpiresAtMs <= startupNowMs {
|
||||
return nil, errors.New(
|
||||
"Room database active-room lease deadline must be in the future")
|
||||
}
|
||||
|
||||
// `new DatabaseSync(this.path, { timeout: 0 })`: a lock conflict must fail
|
||||
// immediately instead of waiting.
|
||||
db, err := sql.Open("sqlite", "file:"+uriPath(d.path)+"?_pragma=busy_timeout(0)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
db.SetConnMaxIdleTime(0)
|
||||
conn, err := db.Conn(context.Background())
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.db, d.conn = db, conn
|
||||
defer func() {
|
||||
if err != nil {
|
||||
d.closeHandles()
|
||||
}
|
||||
}()
|
||||
|
||||
// The pragma answers "exclusive" even when another process owns the file;
|
||||
// the BEGIN EXCLUSIVE below is what actually takes the lock.
|
||||
var lockingMode string
|
||||
if err = d.conn.QueryRowContext(
|
||||
context.Background(), "PRAGMA main.locking_mode = EXCLUSIVE").Scan(&lockingMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lockingMode != "exclusive" {
|
||||
return nil, errors.New("Room database could not acquire exclusive locking mode")
|
||||
}
|
||||
if err = d.begin("BEGIN EXCLUSIVE"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restored, err = d.recover(startupNowMs, activeLeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
d.rollbackIfNeeded()
|
||||
return nil, err
|
||||
}
|
||||
if err = d.finish("COMMIT"); err != nil {
|
||||
d.rollbackIfNeeded()
|
||||
return nil, err
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// uriPath is the literal path `new DatabaseSync(this.path)` opened, written for
|
||||
// the driver's URI DSN: the driver splits on the first `?` and SQLite decodes
|
||||
// `%HH` and stops at `#`, so those three characters are escaped.
|
||||
func uriPath(path string) string {
|
||||
return strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F").Replace(filepath.ToSlash(path))
|
||||
}
|
||||
|
||||
func (d *Database) recover(
|
||||
startupNowMs, activeLeaseExpiresAtMs int64,
|
||||
) ([]StoredRoomAuthority, error) {
|
||||
if err := d.ensureExactSchema(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.assertDatabaseIntegrity(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored, err := d.readStoredRooms()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiredCount, activeCount := 0, 0
|
||||
for _, room := range stored {
|
||||
switch {
|
||||
case room.LeaseExpiresAtMs == nil:
|
||||
activeCount++
|
||||
case *room.LeaseExpiresAtMs <= startupNowMs:
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := d.exec(
|
||||
`DELETE FROM rooms
|
||||
WHERE lease_expires_at_ms IS NOT NULL
|
||||
AND lease_expires_at_ms <= ?`, startupNowMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted != int64(expiredCount) {
|
||||
return nil, errors.New("Room database expiry cleanup changed an unexpected row count")
|
||||
}
|
||||
|
||||
converted, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET lease_expires_at_ms = ?
|
||||
WHERE lease_expires_at_ms IS NULL`, activeLeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if converted != int64(activeCount) {
|
||||
return nil, errors.New("Room database active-room recovery changed an unexpected row count")
|
||||
}
|
||||
|
||||
restored := make([]StoredRoomAuthority, 0, len(stored))
|
||||
for _, room := range stored {
|
||||
if room.LeaseExpiresAtMs != nil && *room.LeaseExpiresAtMs <= startupNowMs {
|
||||
continue
|
||||
}
|
||||
if room.LeaseExpiresAtMs == nil {
|
||||
lease := activeLeaseExpiresAtMs
|
||||
room.LeaseExpiresAtMs = &lease
|
||||
}
|
||||
restored = append(restored, room)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// InsertRoom is insertRoom.
|
||||
func (d *Database) InsertRoom(room StoredRoomAuthority) error {
|
||||
if err := assertStoredRoom(room); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(insertStoredRoom,
|
||||
room.RoomID,
|
||||
room.HostTokenDigest,
|
||||
room.ViewerGrantDigest,
|
||||
room.ViewerAuthorizationGeneration,
|
||||
room.CodeEntryPolicy,
|
||||
room.ViewerPasswordMaterial,
|
||||
room.LeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "insert")
|
||||
})
|
||||
}
|
||||
|
||||
// SetLeaseDeadline is setLeaseDeadline; a nil deadline marks the room active.
|
||||
func (d *Database) SetLeaseDeadline(
|
||||
roomID string, hostTokenDigest []byte, leaseExpiresAtMs *int64,
|
||||
) error {
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if leaseExpiresAtMs != nil {
|
||||
if err := assertTimestamp(*leaseExpiresAtMs, "Room lease deadline", false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET lease_expires_at_ms = ?
|
||||
WHERE room_id = ? AND host_token_digest = ?`,
|
||||
leaseExpiresAtMs, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "lease update")
|
||||
})
|
||||
}
|
||||
|
||||
// SetViewerPassword is setViewerPassword; nil material clears the password.
|
||||
func (d *Database) SetViewerPassword(
|
||||
roomID string, hostTokenDigest, viewerPasswordMaterial []byte,
|
||||
) error {
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertPasswordMaterial(viewerPasswordMaterial); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET viewer_password_material = ?
|
||||
WHERE room_id = ? AND host_token_digest = ?`,
|
||||
viewerPasswordMaterial, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "password update")
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodeEntryPolicy is setCodeEntryPolicy.
|
||||
func (d *Database) SetCodeEntryPolicy(
|
||||
roomID string, hostTokenDigest []byte, codeEntryPolicy protocol.CodeEntryPolicy,
|
||||
) error {
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validCodeEntryPolicy(codeEntryPolicy) {
|
||||
return errors.New("Room code-entry policy is invalid")
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET code_entry_policy = ?
|
||||
WHERE room_id = ? AND host_token_digest = ?`,
|
||||
codeEntryPolicy, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "code-entry policy update")
|
||||
})
|
||||
}
|
||||
|
||||
// SetViewerGrant is setViewerGrant.
|
||||
func (d *Database) SetViewerGrant(
|
||||
roomID string, hostTokenDigest, viewerGrantDigest []byte,
|
||||
viewerAuthorizationGeneration string,
|
||||
) error {
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertDigest(viewerGrantDigest, "Viewer grant"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertAuthorizationGeneration(viewerAuthorizationGeneration); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET viewer_grant_digest = ?,
|
||||
viewer_authorization_generation = ?
|
||||
WHERE room_id = ? AND host_token_digest = ?`,
|
||||
viewerGrantDigest, viewerAuthorizationGeneration, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "Viewer grant update")
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRoom is deleteRoom.
|
||||
func (d *Database) DeleteRoom(roomID string, hostTokenDigest []byte) error {
|
||||
return d.DeleteRooms([]RoomIdentity{{RoomID: roomID, HostTokenDigest: hostTokenDigest}})
|
||||
}
|
||||
|
||||
// DeleteRooms is deleteRooms: one transaction, and any room whose authority no
|
||||
// longer matches rolls the whole batch back.
|
||||
func (d *Database) DeleteRooms(rooms []RoomIdentity) error {
|
||||
for _, room := range rooms {
|
||||
if err := assertRoomIdentity(room.RoomID, room.HostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(rooms) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
for _, room := range rooms {
|
||||
changes, err := d.exec(deleteRoomStatement, room.RoomID, room.HostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertSingleChange(changes, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceRoom is replaceRoom: the new room and the removal of the old one are
|
||||
// one authority transition.
|
||||
func (d *Database) ReplaceRoom(
|
||||
oldRoomID string, oldHostTokenDigest []byte, replacement StoredRoomAuthority,
|
||||
) error {
|
||||
if err := assertRoomIdentity(oldRoomID, oldHostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertStoredRoom(replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
if replacement.RoomID == oldRoomID {
|
||||
return errors.New("Replacement room ID must be different")
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
inserted, err := d.exec(insertStoredRoom,
|
||||
replacement.RoomID,
|
||||
replacement.HostTokenDigest,
|
||||
replacement.ViewerGrantDigest,
|
||||
replacement.ViewerAuthorizationGeneration,
|
||||
replacement.CodeEntryPolicy,
|
||||
replacement.ViewerPasswordMaterial,
|
||||
replacement.LeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertSingleChange(inserted, "replacement insert"); err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := d.exec(deleteRoomStatement, oldRoomID, oldHostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(deleted, "replacement delete")
|
||||
})
|
||||
}
|
||||
|
||||
// Close closes the connection; it is safe on a database that never opened one.
|
||||
func (d *Database) Close() error {
|
||||
return d.closeHandles()
|
||||
}
|
||||
|
||||
func (d *Database) closeHandles() error {
|
||||
conn, db := d.conn, d.db
|
||||
d.conn, d.db, d.inTransaction = nil, nil, false
|
||||
var connError error
|
||||
if conn != nil {
|
||||
connError = conn.Close()
|
||||
}
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return connError
|
||||
}
|
||||
|
||||
func (d *Database) ensureExactSchema() error {
|
||||
applicationID, err := d.readPragmaInteger("application_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schemaVersion, err := d.readPragmaInteger("user_version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schemaObjects, err := d.readSchemaObjects()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if applicationID == 0 && schemaVersion == 0 && len(schemaObjects) == 0 {
|
||||
_, err := d.conn.ExecContext(context.Background(), roomSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
if applicationID != roomDatabaseApplicationID {
|
||||
return errors.New("Room database application identity does not match")
|
||||
}
|
||||
if schemaVersion != roomDatabaseSchemaVersion {
|
||||
return errors.New("Room database schema version does not match")
|
||||
}
|
||||
if len(schemaObjects) != 1 ||
|
||||
schemaObjects[0][0] != "table" || schemaObjects[0][1] != "rooms" {
|
||||
return errors.New("Room database schema objects do not match")
|
||||
}
|
||||
return d.assertExactColumns()
|
||||
}
|
||||
|
||||
func (d *Database) readSchemaObjects() ([][2]string, error) {
|
||||
rows, err := d.conn.QueryContext(context.Background(),
|
||||
`SELECT type, name
|
||||
FROM sqlite_schema
|
||||
WHERE name NOT LIKE 'sqlite_%'
|
||||
ORDER BY type, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var objects [][2]string
|
||||
for rows.Next() {
|
||||
var object [2]string
|
||||
if err := rows.Scan(&object[0], &object[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects = append(objects, object)
|
||||
}
|
||||
return objects, rows.Err()
|
||||
}
|
||||
|
||||
func (d *Database) assertDatabaseIntegrity() error {
|
||||
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA quick_check")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var results []string
|
||||
for rows.Next() {
|
||||
var result string
|
||||
if err := rows.Scan(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) != 1 || results[0] != "ok" {
|
||||
return errors.New("Room database integrity check failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) assertExactColumns() error {
|
||||
expected := [...]struct {
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
primaryKey int
|
||||
}{
|
||||
{"room_id", "TEXT", 1, 1},
|
||||
{"host_token_digest", "BLOB", 1, 0},
|
||||
{"viewer_grant_digest", "BLOB", 1, 0},
|
||||
{"viewer_authorization_generation", "TEXT", 1, 0},
|
||||
{"code_entry_policy", "TEXT", 1, 0},
|
||||
{"viewer_password_material", "BLOB", 0, 0},
|
||||
{"lease_expires_at_ms", "INTEGER", 0, 0},
|
||||
}
|
||||
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA table_info(rooms)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
index := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultValue sql.NullString
|
||||
primaryKey int
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if index >= len(expected) || name != expected[index].name ||
|
||||
columnType != expected[index].columnType ||
|
||||
notNull != expected[index].notNull ||
|
||||
primaryKey != expected[index].primaryKey {
|
||||
return errors.New("Room database columns do not match the current schema")
|
||||
}
|
||||
index++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if index != len(expected) {
|
||||
return errors.New("Room database columns do not match the current schema")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
|
||||
rows, err := d.conn.QueryContext(context.Background(), selectStoredRooms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var stored []StoredRoomAuthority
|
||||
for rows.Next() {
|
||||
var (
|
||||
room StoredRoomAuthority
|
||||
policy string
|
||||
lease sql.NullInt64
|
||||
material []byte
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&room.RoomID,
|
||||
&room.HostTokenDigest,
|
||||
&room.ViewerGrantDigest,
|
||||
&room.ViewerAuthorizationGeneration,
|
||||
&policy,
|
||||
&material,
|
||||
&lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := readDigest(room.HostTokenDigest, "Host token"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := readDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validCodeEntryPolicy(policy) {
|
||||
return nil, errors.New("Room database contains an invalid code-entry policy")
|
||||
}
|
||||
room.CodeEntryPolicy = policy
|
||||
if material != nil {
|
||||
if len(material) != viewerPasswordMaterialBytes {
|
||||
return nil, errors.New("Room database contains invalid Viewer password material")
|
||||
}
|
||||
room.ViewerPasswordMaterial = material
|
||||
}
|
||||
if lease.Valid {
|
||||
if lease.Int64 <= 0 || lease.Int64 > protocol.MaxSafeInteger {
|
||||
return nil, errors.New("Room database contains an invalid lease deadline")
|
||||
}
|
||||
room.LeaseExpiresAtMs = &lease.Int64
|
||||
}
|
||||
if err := assertStoredRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored = append(stored, room)
|
||||
}
|
||||
return stored, rows.Err()
|
||||
}
|
||||
|
||||
func (d *Database) readPragmaInteger(name string) (int64, error) {
|
||||
var value int64
|
||||
if err := d.conn.QueryRowContext(
|
||||
context.Background(), "PRAGMA "+name).Scan(&value); err != nil {
|
||||
return 0, fmt.Errorf("Room database %s is invalid", name)
|
||||
}
|
||||
if value < -protocol.MaxSafeInteger || value > protocol.MaxSafeInteger {
|
||||
return 0, fmt.Errorf("Room database %s is invalid", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (d *Database) transaction(operation func() error) error {
|
||||
if d.conn == nil {
|
||||
return errors.New("Room database is not initialized")
|
||||
}
|
||||
if err := d.begin("BEGIN IMMEDIATE"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := operation(); err != nil {
|
||||
d.rollbackIfNeeded()
|
||||
return err
|
||||
}
|
||||
if err := d.finish("COMMIT"); err != nil {
|
||||
d.rollbackIfNeeded()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) begin(statement string) error {
|
||||
if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
|
||||
return err
|
||||
}
|
||||
d.inTransaction = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) finish(statement string) error {
|
||||
if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
|
||||
return err
|
||||
}
|
||||
d.inTransaction = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackIfNeeded is `if (database.isTransaction) database.exec("ROLLBACK")`:
|
||||
// a ROLLBACK with no active transaction is itself an error.
|
||||
func (d *Database) rollbackIfNeeded() {
|
||||
if d.inTransaction {
|
||||
_ = d.finish("ROLLBACK")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) exec(query string, args ...any) (int64, error) {
|
||||
result, err := d.conn.ExecContext(context.Background(), query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func assertStoredRoom(room StoredRoomAuthority) error {
|
||||
if !protocol.ValidRoomCode(room.RoomID) {
|
||||
return errors.New("Room database contains an invalid room ID")
|
||||
}
|
||||
if err := assertDigest(room.HostTokenDigest, "Host token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertAuthorizationGeneration(room.ViewerAuthorizationGeneration); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validCodeEntryPolicy(room.CodeEntryPolicy) {
|
||||
return errors.New("Room database contains an invalid code-entry policy")
|
||||
}
|
||||
if err := assertPasswordMaterial(room.ViewerPasswordMaterial); err != nil {
|
||||
return err
|
||||
}
|
||||
if room.LeaseExpiresAtMs != nil {
|
||||
return assertTimestamp(*room.LeaseExpiresAtMs, "Room database lease deadline", false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertRoomIdentity(roomID string, hostTokenDigest []byte) error {
|
||||
if !protocol.ValidRoomCode(roomID) {
|
||||
return errors.New("Room ID is invalid")
|
||||
}
|
||||
return assertDigest(hostTokenDigest, "Host token")
|
||||
}
|
||||
|
||||
func readDigest(value []byte, name string) error {
|
||||
if len(value) != 32 {
|
||||
return fmt.Errorf("Room database contains an invalid %s digest", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertDigest(value []byte, name string) error {
|
||||
if len(value) != 32 {
|
||||
return fmt.Errorf("%s digest must contain 32 bytes", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertPasswordMaterial(value []byte) error {
|
||||
if value != nil && len(value) != viewerPasswordMaterialBytes {
|
||||
return errors.New("Viewer password material must contain 48 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertAuthorizationGeneration(value string) error {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil || len(decoded) != 16 ||
|
||||
base64.RawURLEncoding.EncodeToString(decoded) != value {
|
||||
return errors.New("Viewer authorization generation is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertTimestamp is the TypeScript Number.isSafeInteger guard: SQLite STRICT
|
||||
// accepts an integral REAL and a numeric TEXT in an INTEGER column, so the
|
||||
// range check has to stay on this side.
|
||||
func assertTimestamp(value int64, name string, allowZero bool) error {
|
||||
if value > protocol.MaxSafeInteger || (allowZero && value < 0) || (!allowZero && value <= 0) {
|
||||
return fmt.Errorf("%s must be a safe positive integer", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertSingleChange(changes int64, operation string) error {
|
||||
if changes != 1 {
|
||||
return fmt.Errorf("Room database %s did not match current authority", operation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCodeEntryPolicy(policy protocol.CodeEntryPolicy) bool {
|
||||
return policy == protocol.CodeEntryOpen || policy == protocol.CodeEntryPrivate
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// The scenarios below are ported from tests/room-database.test.ts.
|
||||
|
||||
func databasePath(t *testing.T) string {
|
||||
t.Helper()
|
||||
// t.TempDir registers its removal before any store cleanup below, so the
|
||||
// database is always closed first; Windows will not remove an open file.
|
||||
return filepath.Join(t.TempDir(), "rooms.sqlite")
|
||||
}
|
||||
|
||||
func stableStore(t *testing.T, path string, testClock *clock, options Options) *Store {
|
||||
t.Helper()
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if options.LeaseMs == 0 {
|
||||
options.LeaseMs = 1_000
|
||||
}
|
||||
if options.MaxRooms == 0 {
|
||||
options.MaxRooms = 8
|
||||
}
|
||||
options.MaxViewersPerRoom = 8
|
||||
options.Database = database
|
||||
options.Now = func() int64 { return testClock.nowMs }
|
||||
store, err := New(options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func rawDatabase(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func rawExec(t *testing.T, path, statement string) {
|
||||
t.Helper()
|
||||
if _, err := rawDatabase(t, path).Exec(statement); err != nil {
|
||||
t.Fatalf("raw exec %q: %v", statement, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dbViewerInput(roomID, sessionID, viewerGrant string) ConnectParticipantInput {
|
||||
input := viewerInput(roomID, sessionID, viewerGrant)
|
||||
input.ClientID = "client-" + sessionID
|
||||
return input
|
||||
}
|
||||
|
||||
func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 1_000}
|
||||
first := stableStore(t, path, testClock, Options{MaxRooms: 1})
|
||||
|
||||
withSaturatedGate(t, first, func() {
|
||||
_, err := tryCreateRoom(first, protocol.CodeEntryPrivate, "room-password", "4321")
|
||||
expectCode(t, err, CodeRoomBusy)
|
||||
})
|
||||
if first.Size() != 0 {
|
||||
t.Fatalf("size = %d, want 0", first.Size())
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{MaxRooms: 1})
|
||||
if second.Size() != 0 {
|
||||
t.Fatalf("restored size = %d, want 0", second.Size())
|
||||
}
|
||||
if created := createRoom(t, second, protocol.CodeEntryPrivate, "", "4321"); created.RoomID != "4321" {
|
||||
t.Fatalf("room = %q, want 4321", created.RoomID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 1_000}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
if _, err := first.SetCodeEntryPolicy(
|
||||
room.RoomID, protocol.CodeEntryPrivate, room.HostToken); err != nil {
|
||||
t.Fatalf("SetCodeEntryPolicy: %v", err)
|
||||
}
|
||||
if _, err := trySetViewerPassword(
|
||||
first, room.RoomID, "room-password", room.HostToken); err != nil {
|
||||
t.Fatalf("SetViewerPassword: %v", err)
|
||||
}
|
||||
rotated, err := first.SetViewerGrant(room.RoomID, "rotate", room.HostToken)
|
||||
if err != nil {
|
||||
t.Fatalf("SetViewerGrant: %v", err)
|
||||
}
|
||||
host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
|
||||
oldViewer := mustConnect(t, first, dbViewerInput(room.RoomID, "old-viewer", rotated.ViewerGrant))
|
||||
if connected, ok := first.GetConnectedHost(room.RoomID); !ok || connected.PeerID != host.PeerID {
|
||||
t.Fatalf("host = %+v %v", connected, ok)
|
||||
}
|
||||
if _, ok := first.GetConnectedViewer(room.RoomID, oldViewer.PeerID); !ok {
|
||||
t.Fatal("viewer is not connected")
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 2_000
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
if second.Size() != 1 {
|
||||
t.Fatalf("restored size = %d, want 1", second.Size())
|
||||
}
|
||||
if _, ok := second.GetConnectedHost(room.RoomID); ok {
|
||||
t.Fatal("a restored room reported a connected host")
|
||||
}
|
||||
if viewers := second.GetConnectedViewers(room.RoomID); len(viewers) != 0 {
|
||||
t.Fatalf("restored viewers = %+v", viewers)
|
||||
}
|
||||
_, err = second.ConnectParticipant(dbViewerInput(room.RoomID, "revoked-grant", room.ViewerGrant))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
|
||||
granted := mustConnect(t, second, dbViewerInput(room.RoomID, "current-grant", rotated.ViewerGrant))
|
||||
if granted.ExpiresAt == nil || *granted.ExpiresAt != "1970-01-01T00:00:03.000Z" {
|
||||
t.Fatalf("expiresAt = %v", granted.ExpiresAt)
|
||||
}
|
||||
if granted.ViewerAuthorizationGeneration != rotated.ViewerAuthorizationGeneration {
|
||||
t.Fatalf("generation = %q, want %q",
|
||||
granted.ViewerAuthorizationGeneration, rotated.ViewerAuthorizationGeneration)
|
||||
}
|
||||
connected, err := tryConnectViewerWithPassword(second, ConnectViewerWithPasswordInput{
|
||||
RoomID: room.RoomID,
|
||||
Password: "room-password",
|
||||
ClientID: "password-client",
|
||||
SessionID: "password-session",
|
||||
}, nil)
|
||||
if err != nil || connected.CodeEntryPolicy != protocol.CodeEntryPrivate {
|
||||
t.Fatalf("password login = %+v, %v", connected, err)
|
||||
}
|
||||
_, err = second.ConnectParticipant(hostInput(room.RoomID, "wrong-token"))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
resumed := mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host"))
|
||||
if resumed.ExpiresAt != nil {
|
||||
t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt)
|
||||
}
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
raw := rawDatabase(t, path)
|
||||
rows, err := raw.Query("SELECT * FROM rooms")
|
||||
if err != nil {
|
||||
t.Fatalf("raw query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatalf("Columns: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"room_id", "host_token_digest", "viewer_grant_digest",
|
||||
"viewer_authorization_generation", "code_entry_policy",
|
||||
"viewer_password_material", "lease_expires_at_ms",
|
||||
}
|
||||
if strings.Join(columns, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("columns = %v", columns)
|
||||
}
|
||||
if !rows.Next() {
|
||||
t.Fatal("no stored room")
|
||||
}
|
||||
var (
|
||||
roomID string
|
||||
hostDigest []byte
|
||||
grantDigest []byte
|
||||
generation string
|
||||
policy string
|
||||
material []byte
|
||||
leaseExpiresAtMs sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&roomID, &hostDigest, &grantDigest, &generation, &policy,
|
||||
&material, &leaseExpiresAtMs); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
hostTokenDigest := sha256.Sum256([]byte(room.HostToken))
|
||||
if !bytes.Equal(hostDigest, hostTokenDigest[:]) {
|
||||
t.Fatal("stored Host token digest does not match")
|
||||
}
|
||||
grantTokenDigest := sha256.Sum256([]byte(rotated.ViewerGrant))
|
||||
if !bytes.Equal(grantDigest, grantTokenDigest[:]) {
|
||||
t.Fatal("stored Viewer grant digest does not match")
|
||||
}
|
||||
if len(material) != 48 {
|
||||
t.Fatalf("password material = %d bytes, want 48", len(material))
|
||||
}
|
||||
// Nothing reversible is stored: no token, grant or password appears.
|
||||
stored := roomID + generation + policy + string(hostDigest) + string(grantDigest) + string(material)
|
||||
for _, secret := range []string{room.HostToken, rotated.ViewerGrant, "room-password"} {
|
||||
if strings.Contains(stored, secret) {
|
||||
t.Fatal("a credential was stored verbatim")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertsAnActiveMarkerOnceAndPreservesDormantDeadlines(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
|
||||
mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 500
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
firstRestart := mustConnect(t, second, dbViewerInput(room.RoomID, "first-restart", room.ViewerGrant))
|
||||
if firstRestart.ExpiresAt == nil || *firstRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
|
||||
t.Fatalf("expiresAt = %v", firstRestart.ExpiresAt)
|
||||
}
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 700
|
||||
third := stableStore(t, path, testClock, Options{})
|
||||
secondRestart := mustConnect(t, third, dbViewerInput(room.RoomID, "second-restart", room.ViewerGrant))
|
||||
if secondRestart.ExpiresAt == nil || *secondRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
|
||||
t.Fatalf("expiresAt = %v", secondRestart.ExpiresAt)
|
||||
}
|
||||
if err := third.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 1_501
|
||||
fourth := stableStore(t, path, testClock, Options{})
|
||||
if fourth.Size() != 0 {
|
||||
t.Fatalf("size = %d, want 0", fourth.Size())
|
||||
}
|
||||
replacement := createRoom(t, fourth, protocol.CodeEntryOpen, "", room.RoomID)
|
||||
if replacement.RoomID != room.RoomID {
|
||||
t.Fatalf("replacement room = %q", replacement.RoomID)
|
||||
}
|
||||
_, err := fourth.ConnectParticipant(hostInput(room.RoomID, room.HostToken))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
_, err = fourth.ConnectParticipant(dbViewerInput(room.RoomID, "stale-grant", room.ViewerGrant))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
}
|
||||
|
||||
func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5890")
|
||||
host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
|
||||
testClock.nowMs = 250
|
||||
if _, err := first.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil {
|
||||
t.Fatalf("DisconnectParticipant: %v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 500
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
dormant := mustConnect(t, second, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant))
|
||||
if dormant.ExpiresAt == nil || *dormant.ExpiresAt != "1970-01-01T00:00:01.250Z" {
|
||||
t.Fatalf("expiresAt = %v", dormant.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "6789")
|
||||
closed, err := first.AbandonRoom(room.RoomID)
|
||||
if err != nil || closed == nil || closed.RoomID != room.RoomID {
|
||||
t.Fatalf("AbandonRoom = %v, %v", closed, err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
if second.Size() != 0 {
|
||||
t.Fatalf("size = %d, want 0", second.Size())
|
||||
}
|
||||
replacement := createRoom(t, second, protocol.CodeEntryOpen, "", room.RoomID)
|
||||
if replacement.RoomID != room.RoomID {
|
||||
t.Fatalf("replacement room = %q", replacement.RoomID)
|
||||
}
|
||||
if replacement.HostToken == room.HostToken || replacement.ViewerGrant == room.ViewerGrant {
|
||||
t.Fatal("the recycled code kept the old credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollsBackABatchExpiryWhenAnyAuthorityIsStale(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
restored, err := database.Initialize(testClock.nowMs, 1_010)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if len(restored) != 2 {
|
||||
t.Fatalf("restored %d rooms, want 2", len(restored))
|
||||
}
|
||||
err = database.DeleteRooms([]RoomIdentity{
|
||||
{RoomID: restored[0].RoomID, HostTokenDigest: restored[0].HostTokenDigest},
|
||||
{RoomID: restored[1].RoomID, HostTokenDigest: make([]byte, 32)},
|
||||
})
|
||||
if err == nil || err.Error() != "Room database delete did not match current authority" {
|
||||
t.Fatalf("DeleteRooms: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
if second.Size() != 2 {
|
||||
t.Fatalf("size = %d, want 2", second.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
original := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
replacement, err := tryReplaceRoom(
|
||||
first, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "new-password")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceRoom: %v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
if second.Size() != 1 {
|
||||
t.Fatalf("size = %d, want 1", second.Size())
|
||||
}
|
||||
_, err = second.ConnectParticipant(hostInput(original.RoomID, original.HostToken))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
connected := mustConnect(t, second, dbViewerInput(
|
||||
replacement.Created.RoomID, "replacement-viewer", replacement.Created.ViewerGrant))
|
||||
if connected.RoomID != replacement.Created.RoomID {
|
||||
t.Fatalf("viewer joined %q", connected.RoomID)
|
||||
}
|
||||
withPassword, err := tryConnectViewerWithPassword(second, ConnectViewerWithPasswordInput{
|
||||
RoomID: replacement.Created.RoomID,
|
||||
Password: "new-password",
|
||||
ClientID: "password-viewer",
|
||||
SessionID: "password-session",
|
||||
}, nil)
|
||||
if err != nil || withPassword.RoomID != replacement.Created.RoomID {
|
||||
t.Fatalf("password login = %+v, %v", withPassword, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpensThePathWithURIReservedCharactersLiterally: `new DatabaseSync(path)`
|
||||
// opened the literal path, while the driver's DSN is a URI in which `%41`
|
||||
// decodes to `A` and `#` ends the file name; the file must still be the
|
||||
// configured one, and the busy-timeout pragma must survive the escaping.
|
||||
func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) {
|
||||
testClock := &clock{nowMs: 10}
|
||||
path := filepath.Join(t.TempDir(), "rooms%41#1.sqlite")
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("the configured file was not the one opened: %v", err)
|
||||
}
|
||||
if second := stableStore(t, path, testClock, Options{}); second.Size() != 1 {
|
||||
t.Fatalf("restored size = %d, want 1", second.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
first, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := first.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
contender, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = contender.Initialize(0, 1_000)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") {
|
||||
t.Fatalf("contender Initialize = %v, want a lock failure", err)
|
||||
}
|
||||
if err := contender.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
successor, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
restored, err := successor.Initialize(0, 1_000)
|
||||
if err != nil || len(restored) != 0 {
|
||||
t.Fatalf("successor Initialize = %v, %v", restored, err)
|
||||
}
|
||||
if err := successor.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, mutation string }{
|
||||
{"application identity", "PRAGMA application_id = 1"},
|
||||
{"schema version", "PRAGMA user_version = 2"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
initial, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := initial.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := initial.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
rawExec(t, path, testCase.mutation)
|
||||
|
||||
reopened, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = reopened.Initialize(0, 1_000)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
initial, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := initial.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := initial.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
rawExec(t, path, "ALTER TABLE rooms ADD COLUMN unexpected TEXT")
|
||||
|
||||
reopened, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = reopened.Initialize(0, 1_000)
|
||||
if err == nil || err.Error() != "Room database columns do not match the current schema" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsCorruptAuthorityRows(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
initial := stableStore(t, path, testClock, Options{})
|
||||
createRoom(t, initial, protocol.CodeEntryOpen, "", "7890")
|
||||
if err := initial.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
raw := rawDatabase(t, path)
|
||||
if _, err := raw.Exec("PRAGMA ignore_check_constraints = ON"); err != nil {
|
||||
t.Fatalf("raw pragma: %v", err)
|
||||
}
|
||||
if _, err := raw.Exec("UPDATE rooms SET host_token_digest = zeroblob(31)"); err != nil {
|
||||
t.Fatalf("raw update: %v", err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatalf("raw close: %v", err)
|
||||
}
|
||||
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
reopened, err := New(Options{
|
||||
LeaseMs: 1_000,
|
||||
MaxRooms: 8,
|
||||
MaxViewersPerRoom: 8,
|
||||
Now: func() int64 { return testClock.nowMs },
|
||||
Database: database,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// The oracle accepts either rejection: this SQLite build reports the broken
|
||||
// row from PRAGMA quick_check before the digest length assertion runs.
|
||||
err = reopened.Initialize()
|
||||
if err == nil || (!strings.Contains(err.Error(), "Host token") &&
|
||||
!strings.Contains(err.Error(), "integrity")) {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectsInvalidAuthorityArguments keeps the assertions the TypeScript ran
|
||||
// before touching SQLite: STRICT would accept an integral REAL or a numeric
|
||||
// TEXT timestamp, and a caller mistake must not reach the file at all.
|
||||
func TestRejectsInvalidAuthorityArguments(t *testing.T) {
|
||||
valid := StoredRoomAuthority{
|
||||
RoomID: "1234",
|
||||
HostTokenDigest: make([]byte, 32),
|
||||
ViewerGrantDigest: make([]byte, 32),
|
||||
ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw",
|
||||
CodeEntryPolicy: protocol.CodeEntryOpen,
|
||||
}
|
||||
lease := int64(protocol.MaxSafeInteger + 1)
|
||||
shortDigest := make([]byte, 31)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
run func(*Database) error
|
||||
want string
|
||||
}{
|
||||
{"room ID", func(d *Database) error {
|
||||
room := valid
|
||||
room.RoomID = "0123"
|
||||
return d.InsertRoom(room)
|
||||
}, "Room database contains an invalid room ID"},
|
||||
{"host digest", func(d *Database) error {
|
||||
room := valid
|
||||
room.HostTokenDigest = shortDigest
|
||||
return d.InsertRoom(room)
|
||||
}, "Host token digest must contain 32 bytes"},
|
||||
{"grant digest", func(d *Database) error {
|
||||
room := valid
|
||||
room.ViewerGrantDigest = shortDigest
|
||||
return d.InsertRoom(room)
|
||||
}, "Viewer grant digest must contain 32 bytes"},
|
||||
{"generation", func(d *Database) error {
|
||||
room := valid
|
||||
room.ViewerAuthorizationGeneration = "MzMzMzMzMzMzMzMzMzMzMw=="
|
||||
return d.InsertRoom(room)
|
||||
}, "Viewer authorization generation is invalid"},
|
||||
{"policy", func(d *Database) error {
|
||||
room := valid
|
||||
room.CodeEntryPolicy = "public"
|
||||
return d.InsertRoom(room)
|
||||
}, "Room database contains an invalid code-entry policy"},
|
||||
{"password material", func(d *Database) error {
|
||||
room := valid
|
||||
room.ViewerPasswordMaterial = make([]byte, 47)
|
||||
return d.InsertRoom(room)
|
||||
}, "Viewer password material must contain 48 bytes"},
|
||||
{"lease deadline", func(d *Database) error {
|
||||
room := valid
|
||||
room.LeaseExpiresAtMs = &lease
|
||||
return d.InsertRoom(room)
|
||||
}, "Room database lease deadline must be a safe positive integer"},
|
||||
{"identity", func(d *Database) error {
|
||||
return d.SetLeaseDeadline("123", make([]byte, 32), nil)
|
||||
}, "Room ID is invalid"},
|
||||
{"update policy", func(d *Database) error {
|
||||
return d.SetCodeEntryPolicy("1234", make([]byte, 32), "public")
|
||||
}, "Room code-entry policy is invalid"},
|
||||
{"replacement identity", func(d *Database) error {
|
||||
return d.ReplaceRoom("1234", make([]byte, 32), valid)
|
||||
}, "Replacement room ID must be different"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
database, err := NewDatabase(databasePath(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
if err := testCase.run(database); err == nil || err.Error() != testCase.want {
|
||||
t.Fatalf("error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAMemoryDatabasePath(t *testing.T) {
|
||||
for _, path := range []string{"", ":memory:"} {
|
||||
if _, err := NewDatabase(path); err == nil ||
|
||||
err.Error() != "Room database path must identify a file" {
|
||||
t.Fatalf("NewDatabase(%q) = %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsASecondInitialize(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err == nil ||
|
||||
err.Error() != "Room database is already initialized" {
|
||||
t.Fatalf("second Initialize = %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := database.InsertRoom(StoredRoomAuthority{
|
||||
RoomID: "1234",
|
||||
HostTokenDigest: make([]byte, 32),
|
||||
ViewerGrantDigest: make([]byte, 32),
|
||||
ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw",
|
||||
CodeEntryPolicy: protocol.CodeEntryOpen,
|
||||
}); err == nil || err.Error() != "Room database is not initialized" {
|
||||
t.Fatalf("InsertRoom after Close = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAnActiveLeaseThatIsNotInTheFuture(t *testing.T) {
|
||||
database, err := NewDatabase(databasePath(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(1_000, 1_000); err == nil ||
|
||||
err.Error() != "Room database active-room lease deadline must be in the future" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(-1, 1_000); err == nil ||
|
||||
err.Error() != "Room database startup time must be a safe positive integer" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package room ports the room authority: src/server/room-store.ts (rooms,
|
||||
// participants, room codes, viewer credentials, the four-digit code pool) and
|
||||
// src/server/room-database.ts (the SQLite stable authority). Every mutation
|
||||
// writes SQLite before memory and returns the write error unchanged; there is
|
||||
// no catch-and-continue anywhere, exactly as in the TypeScript.
|
||||
//
|
||||
// # Locking
|
||||
//
|
||||
// Store has no mutex. The signaling layer's single global lock guards it, which
|
||||
// is what Node's single thread did: nothing interleaved except at an await. The
|
||||
// caller must hold that lock for every method below except the two derive
|
||||
// methods, which run the password KDF and must NOT be called under the lock.
|
||||
//
|
||||
// The TypeScript methods that await the KDF are split into a locked "begin",
|
||||
// an unlocked derive, and a locked "commit" so that the guards the TypeScript
|
||||
// re-ran after its await are re-run here after the relock, in the same order.
|
||||
// The commit takes the derive result as (material, derived error) because the
|
||||
// TypeScript inspected the gate outcome (busy/cancelled) only after re-reading
|
||||
// the room: forward both values unchanged and the commit reproduces the order.
|
||||
//
|
||||
// POST /api/rooms (createRoom):
|
||||
//
|
||||
// mu.Lock()
|
||||
// lease, err := store.BeginCreateRoom() // ROOM_LIMIT + lease deadline, pre-KDF
|
||||
// if err != nil { mu.Unlock(); return err }
|
||||
// var material []byte
|
||||
// var derived error
|
||||
// if password != "" {
|
||||
// mu.Unlock()
|
||||
// material, derived = store.DeriveViewerPasswordMaterial(password, nil)
|
||||
// mu.Lock()
|
||||
// }
|
||||
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID, lease)
|
||||
// mu.Unlock()
|
||||
//
|
||||
// replaceRoom and set-viewer-password (the mayStart closure is the TypeScript
|
||||
// `rooms.get(roomId) === current && verifyDigest(hostToken, ...)`; it runs on
|
||||
// the derive goroutine, so it takes the lock itself):
|
||||
//
|
||||
// mu.Lock()
|
||||
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN/ROOM_EXPIRED
|
||||
// if err != nil { mu.Unlock(); return err }
|
||||
// var material []byte
|
||||
// var derived error
|
||||
// if password != "" {
|
||||
// mu.Unlock()
|
||||
// material, derived = store.DeriveViewerPasswordMaterial(password, func() bool {
|
||||
// mu.Lock()
|
||||
// defer mu.Unlock()
|
||||
// return store.HostStillOwnsRoom(roomID, hostToken, current)
|
||||
// })
|
||||
// mu.Lock()
|
||||
// }
|
||||
// replaced, err := store.ReplaceRoom(roomID, hostToken, policy, material, derived, current)
|
||||
// mu.Unlock()
|
||||
//
|
||||
// Viewer password login (signaling authenticate). mayConnect is the TypeScript
|
||||
// closure `!closing && socketStates.get(socket) === state && !state.revoked &&
|
||||
// !state.authenticated && socket.readyState === OPEN`. It is evaluated twice,
|
||||
// once on the derive goroutine (gate admission) and once under the lock inside
|
||||
// the commit, so the caller needs both a locking and a non-locking form of the
|
||||
// same predicate:
|
||||
//
|
||||
// mu.Lock()
|
||||
// salt, expected, ref, err := store.ViewerPasswordChallenge(input.RoomID)
|
||||
// mu.Unlock()
|
||||
// if err != nil { return err } // not initialized
|
||||
// derivedKey, err := store.DeriveViewerPassword(input.Password, salt, func() bool {
|
||||
// mu.Lock()
|
||||
// defer mu.Unlock()
|
||||
// return mayConnectLocked()
|
||||
// })
|
||||
// mu.Lock()
|
||||
// if err != nil { mu.Unlock(); return err } // ROOM_BUSY or INVALID_TOKEN
|
||||
// participant, err := store.ConnectViewerWithPassword(
|
||||
// input, derivedKey, expected, ref, mayConnectLocked)
|
||||
// mu.Unlock()
|
||||
//
|
||||
// The room reference handed back by HostManagedRoom and ViewerPasswordChallenge
|
||||
// is only ever compared for pointer identity, reproducing the TypeScript
|
||||
// `currentRoom !== room` checks that detect a room replaced while the KDF ran.
|
||||
// Holding it across the unlock is safe: an abandoned room is dropped from the
|
||||
// map, never mutated into another room.
|
||||
//
|
||||
// Options.Random is called both under the caller's lock and from the derive
|
||||
// goroutine (the 16-byte salt), so it must be safe for concurrent use;
|
||||
// crypto/rand is.
|
||||
package room
|
||||
@@ -0,0 +1,145 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
viewerPasswordSaltBytes = 16
|
||||
viewerPasswordVerifierBytes = 32
|
||||
viewerPasswordMaterialBytes = viewerPasswordSaltBytes + viewerPasswordVerifierBytes
|
||||
|
||||
viewerPasswordScryptN = 16_384
|
||||
viewerPasswordScryptR = 8
|
||||
viewerPasswordScryptP = 1
|
||||
|
||||
// AsyncGate bounds from src/server/room-store.ts (DECISIONS D11): scrypt at
|
||||
// N=16384 costs ~16 MiB and tens of milliseconds, so two concurrent
|
||||
// derivations bound the memory and CPU a room burst can claim, and sixteen
|
||||
// waiters bound the queue before the next caller is told ROOM_BUSY.
|
||||
gateActiveLimit = 2
|
||||
gatePendingLimit = 16
|
||||
)
|
||||
|
||||
// errDerivationCancelled is the AsyncGate "cancelled" outcome. Callers never
|
||||
// inspect it: they hand it to the commit method, which maps it the way the
|
||||
// TypeScript call site did.
|
||||
var errDerivationCancelled = errors.New("password derivation was cancelled")
|
||||
|
||||
// isGateOutcome reports whether err is one of the two AsyncGate outcomes rather
|
||||
// than a thrown error. The TypeScript returned busy and cancelled as values,
|
||||
// which its post-await checks inspected after re-reading the room; everything
|
||||
// else threw out of the await.
|
||||
func isGateOutcome(err error) bool {
|
||||
return errors.Is(err, errDerivationCancelled) || hasCode(err, CodeRoomBusy)
|
||||
}
|
||||
|
||||
// DeriveViewerPasswordMaterial is createViewerPasswordMaterial: it validates the
|
||||
// password, draws a fresh salt and returns salt||verifier. Call it WITHOUT the
|
||||
// caller's lock held and hand both results to CreateRoom, ReplaceRoom or
|
||||
// SetViewerPassword unchanged; those reproduce the TypeScript order in which a
|
||||
// busy or cancelled outcome is turned into an error. mayStart may be nil, which
|
||||
// is the TypeScript default of `() => true`.
|
||||
func (s *Store) DeriveViewerPasswordMaterial(
|
||||
password string, mayStart func() bool,
|
||||
) ([]byte, error) {
|
||||
if !protocol.ValidViewerPassword(password) {
|
||||
return nil, codeError(CodeInvalidToken)
|
||||
}
|
||||
salt := s.random(viewerPasswordSaltBytes)
|
||||
if len(salt) != viewerPasswordSaltBytes {
|
||||
return nil, errors.New("Viewer password salt source must return 16 bytes")
|
||||
}
|
||||
verifier, err := s.deriveViewerPassword(password, salt, mayStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Concat(salt, verifier), nil
|
||||
}
|
||||
|
||||
// DeriveViewerPassword is the unlocked middle of connectViewerWithPassword: it
|
||||
// derives the verifier for the salt ViewerPasswordChallenge returned. Unlike
|
||||
// DeriveViewerPasswordMaterial it maps both gate outcomes itself, because the
|
||||
// TypeScript threw them immediately after the await, before re-reading the room.
|
||||
func (s *Store) DeriveViewerPassword(
|
||||
password string, salt []byte, mayConnect func() bool,
|
||||
) ([]byte, error) {
|
||||
if !protocol.ValidViewerPassword(password) {
|
||||
return nil, codeError(CodeInvalidToken)
|
||||
}
|
||||
derived, err := s.deriveViewerPassword(password, salt, mayConnect)
|
||||
if errors.Is(err, errDerivationCancelled) {
|
||||
return nil, codeError(CodeInvalidToken)
|
||||
}
|
||||
return derived, err
|
||||
}
|
||||
|
||||
func (s *Store) deriveViewerPassword(
|
||||
password string, salt []byte, mayStart func() bool,
|
||||
) ([]byte, error) {
|
||||
return s.gate.run(mayStart, func() ([]byte, error) {
|
||||
return scrypt.Key([]byte(password), salt,
|
||||
viewerPasswordScryptN, viewerPasswordScryptR, viewerPasswordScryptP,
|
||||
viewerPasswordVerifierBytes)
|
||||
})
|
||||
}
|
||||
|
||||
// gate is AsyncGate: at most limit derivations run at once and at most
|
||||
// pendingLimit wait, first in first out. A saturated gate answers ROOM_BUSY.
|
||||
type gate struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
waiters []chan struct{}
|
||||
limit int
|
||||
pendingLimit int
|
||||
}
|
||||
|
||||
func (g *gate) run(mayStart func() bool, task func() ([]byte, error)) ([]byte, error) {
|
||||
if !g.acquire() {
|
||||
return nil, codeError(CodeRoomBusy)
|
||||
}
|
||||
defer g.release()
|
||||
if mayStart != nil && !mayStart() {
|
||||
return nil, errDerivationCancelled
|
||||
}
|
||||
return task()
|
||||
}
|
||||
|
||||
func (g *gate) acquire() bool {
|
||||
g.mu.Lock()
|
||||
if g.active < g.limit {
|
||||
g.active++
|
||||
g.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
if len(g.waiters) >= g.pendingLimit {
|
||||
g.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
wake := make(chan struct{})
|
||||
g.waiters = append(g.waiters, wake)
|
||||
g.mu.Unlock()
|
||||
<-wake
|
||||
return true
|
||||
}
|
||||
|
||||
// release hands the slot to the first waiter instead of decrementing, which is
|
||||
// what `const next = this.waiters.shift(); if (next) { next(); return; }` did.
|
||||
func (g *gate) release() {
|
||||
g.mu.Lock()
|
||||
if len(g.waiters) > 0 {
|
||||
wake := g.waiters[0]
|
||||
g.waiters = g.waiters[1:]
|
||||
g.mu.Unlock()
|
||||
close(wake)
|
||||
return
|
||||
}
|
||||
g.active--
|
||||
g.mu.Unlock()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
@@ -0,0 +1,519 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Resource is the caller-owned SFU/overlap allocation the controller stores,
|
||||
// moves between collections and compares by pointer identity (TS: the bare
|
||||
// Resource type parameter, always used through JS Set semantics). The
|
||||
// controller never reads its fields; they exist for the router and tests.
|
||||
type Resource struct {
|
||||
Kind ResourceKind
|
||||
// EndpointPeerID is set for ResourceOverlap.
|
||||
EndpointPeerID string
|
||||
// Fence fields (RoomID, ShareGeneration, PublicationGeneration) are set
|
||||
// for sfu-publication and sfu-subscription; ViewerPeerID only for the
|
||||
// subscription. All are opaque IDs.
|
||||
RoomID string
|
||||
ShareGeneration string
|
||||
PublicationGeneration string
|
||||
ViewerPeerID string
|
||||
// Released is flipped by the router once the allocation is freed.
|
||||
Released bool
|
||||
}
|
||||
|
||||
// ResourceKind is the RouteResource discriminator of hybrid-media-router.ts.
|
||||
type ResourceKind string
|
||||
|
||||
// Resource kinds.
|
||||
const (
|
||||
ResourceOverlap ResourceKind = "overlap"
|
||||
ResourceSfuSubscription ResourceKind = "sfu-subscription"
|
||||
ResourceSfuPublication ResourceKind = "sfu-publication"
|
||||
)
|
||||
|
||||
// UpstreamKind discriminates CandidateTuple, CommittedEdge, committedEdgeSeed
|
||||
// and QualityUpstream (TS kind: "peer" | "sfu").
|
||||
type UpstreamKind string
|
||||
|
||||
// Upstream kinds.
|
||||
const (
|
||||
UpstreamPeer UpstreamKind = "peer"
|
||||
UpstreamSfu UpstreamKind = "sfu"
|
||||
)
|
||||
|
||||
// Transport is the edge transport (TS transport: "direct" | "sfu").
|
||||
type Transport string
|
||||
|
||||
// Transports.
|
||||
const (
|
||||
TransportDirect Transport = "direct"
|
||||
TransportSfu Transport = "sfu"
|
||||
)
|
||||
|
||||
// PublicationPlan is the SFU tuple action (TS publication: "reuse" | "create" | "replace").
|
||||
type PublicationPlan string
|
||||
|
||||
// Publication plans.
|
||||
const (
|
||||
PublicationReuse PublicationPlan = "reuse"
|
||||
PublicationCreate PublicationPlan = "create"
|
||||
PublicationReplace PublicationPlan = "replace"
|
||||
)
|
||||
|
||||
// ReservationKind discriminates CandidateReservation.
|
||||
type ReservationKind string
|
||||
|
||||
// Reservation kinds.
|
||||
const (
|
||||
ReservationDirect ReservationKind = "direct"
|
||||
ReservationSfuReuse ReservationKind = "sfu-reuse"
|
||||
ReservationSfuCreate ReservationKind = "sfu-create"
|
||||
)
|
||||
|
||||
// RetirementKind discriminates EndpointRetirement.
|
||||
type RetirementKind string
|
||||
|
||||
// Retirement kinds.
|
||||
const (
|
||||
RetireEdge RetirementKind = "edge"
|
||||
RetirePublication RetirementKind = "publication"
|
||||
)
|
||||
|
||||
// TransitionKind discriminates EndpointTransition.
|
||||
type TransitionKind string
|
||||
|
||||
// Transition kinds.
|
||||
const (
|
||||
TransitionNone TransitionKind = "none"
|
||||
TransitionOverlap TransitionKind = "overlap"
|
||||
TransitionBoundedGap TransitionKind = "bounded-gap"
|
||||
)
|
||||
|
||||
// SenderQualityState is the sender-side health sample state
|
||||
// (TS "unknown" | "healthy" | "degraded").
|
||||
type SenderQualityState string
|
||||
|
||||
// Sender quality states.
|
||||
const (
|
||||
SenderQualityUnknown SenderQualityState = "unknown"
|
||||
SenderQualityHealthy SenderQualityState = "healthy"
|
||||
SenderQualityDegraded SenderQualityState = "degraded"
|
||||
)
|
||||
|
||||
// DemandReason mirrors protocol RouteDemandReason.
|
||||
type DemandReason string
|
||||
|
||||
// Demand reasons.
|
||||
const (
|
||||
DemandJoin DemandReason = "join"
|
||||
DemandEdgeUnavailable DemandReason = "edge-unavailable"
|
||||
DemandParentDeparted DemandReason = "parent-departed"
|
||||
DemandCapacityReduction DemandReason = "capacity-reduction"
|
||||
DemandSfuBootstrap DemandReason = "sfu-bootstrap"
|
||||
DemandDirectConvergence DemandReason = "direct-convergence"
|
||||
DemandQualityConvergence DemandReason = "quality-convergence"
|
||||
DemandRootConvergence DemandReason = "root-convergence"
|
||||
)
|
||||
|
||||
// FinalRoute mirrors protocol RouteDiagnosticFinalRoute.
|
||||
type FinalRoute string
|
||||
|
||||
// Final routes.
|
||||
const (
|
||||
FinalRouteDirect FinalRoute = "direct"
|
||||
FinalRouteSfu FinalRoute = "sfu"
|
||||
FinalRouteWaiting FinalRoute = "waiting"
|
||||
FinalRouteFailed FinalRoute = "failed"
|
||||
)
|
||||
|
||||
// RejectionBucket mirrors protocol RouteDiagnosticRejectionBucket.
|
||||
type RejectionBucket string
|
||||
|
||||
// Rejection buckets.
|
||||
const (
|
||||
RejectionNone RejectionBucket = "none"
|
||||
RejectionStale RejectionBucket = "stale"
|
||||
RejectionEndpointCapacity RejectionBucket = "endpoint-capacity"
|
||||
RejectionSfuAdmission RejectionBucket = "sfu-admission"
|
||||
RejectionCandidateFailed RejectionBucket = "candidate-failed"
|
||||
RejectionFirstFrameTimeout RejectionBucket = "first-frame-timeout"
|
||||
RejectionOperationDeadline RejectionBucket = "operation-deadline"
|
||||
RejectionAborted RejectionBucket = "aborted"
|
||||
)
|
||||
|
||||
// RouteQualityEvidenceResult is the ObserveQualityEvidence outcome.
|
||||
type RouteQualityEvidenceResult string
|
||||
|
||||
// Quality evidence outcomes.
|
||||
const (
|
||||
QualityEvidenceRejected RouteQualityEvidenceResult = "rejected"
|
||||
QualityEvidenceAccepted RouteQualityEvidenceResult = "accepted"
|
||||
QualityEvidenceObserved RouteQualityEvidenceResult = "observed"
|
||||
)
|
||||
|
||||
// CandidateTuple is one route candidate: a direct edge from ParentPeerID
|
||||
// (Kind UpstreamPeer, Transport always TransportDirect, Regenerate = rebuild
|
||||
// the same edge with a fresh connection) or an SFU edge (Kind UpstreamSfu,
|
||||
// Publication set). TS: CandidateTuple.
|
||||
type CandidateTuple struct {
|
||||
Kind UpstreamKind
|
||||
ParentPeerID string
|
||||
Transport Transport
|
||||
Regenerate bool
|
||||
Publication PublicationPlan
|
||||
}
|
||||
|
||||
// CandidateReservation is what the caller allocated before
|
||||
// BeginCurrentCandidate. Edge/Publication are set per Kind
|
||||
// (direct: neither; sfu-reuse: Edge, Borrowed when it is another holder's
|
||||
// live subscription and must not be released; sfu-create: Edge and
|
||||
// Publication). Overlap is optional for every kind. TS: CandidateReservation.
|
||||
type CandidateReservation struct {
|
||||
Kind ReservationKind
|
||||
Edge *Resource
|
||||
Publication *Resource
|
||||
Borrowed bool
|
||||
Overlap *Resource
|
||||
}
|
||||
|
||||
// EndpointRetirement is the producer retirement a bounded-gap transition
|
||||
// requires: an existing direct edge (RetireEdge: Child*/Parent*/Transport/
|
||||
// ConnectionID) or the Host publication (RetirePublication: HostSessionID/
|
||||
// Generation/ConnectionID). TS: EndpointRetirement.
|
||||
type EndpointRetirement struct {
|
||||
Kind RetirementKind
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
ParentPeerID string
|
||||
ParentSessionID string
|
||||
Transport Transport
|
||||
ConnectionID string
|
||||
HostSessionID string
|
||||
Generation string
|
||||
}
|
||||
|
||||
// EndpointTransition says how the producer's copy budget absorbs the
|
||||
// candidate. ProducerPeerID is nil only for TransitionNone on an sfu/reuse
|
||||
// plan (TS omitted it there and endpointTransitionEquals compares it).
|
||||
// Retire is set only for TransitionBoundedGap. TS: EndpointTransition.
|
||||
type EndpointTransition struct {
|
||||
Kind TransitionKind
|
||||
ProducerPeerID *string
|
||||
Retire *EndpointRetirement
|
||||
}
|
||||
|
||||
// CandidatePlan is a tuple with its endpoint transition. TS: CandidatePlan.
|
||||
type CandidatePlan struct {
|
||||
Tuple CandidateTuple
|
||||
EndpointTransition EndpointTransition
|
||||
}
|
||||
|
||||
// CommittedEdge is a child's one upstream edge in the committed graph.
|
||||
// Peer edges (Kind UpstreamPeer) set ParentPeerID/ParentSessionID and leave
|
||||
// Resource nil; SFU edges (Kind UpstreamSfu) set PublicationGeneration and
|
||||
// Resource. TS: CommittedEdge.
|
||||
type CommittedEdge struct {
|
||||
Kind UpstreamKind
|
||||
ChildSessionID string
|
||||
ParentPeerID string
|
||||
ParentSessionID string
|
||||
PublicationGeneration string
|
||||
Transport Transport
|
||||
ConnectionID string
|
||||
Usable bool
|
||||
PhysicalActive bool
|
||||
Resource *Resource
|
||||
}
|
||||
|
||||
// committedEdgeSeed is CommittedEdge without the session fields the
|
||||
// controller derives at hydrateEdge. TS: CommittedEdgeSeed, which like
|
||||
// hydrateEdge only ever had test callers.
|
||||
type committedEdgeSeed struct {
|
||||
Kind UpstreamKind
|
||||
ParentPeerID string
|
||||
PublicationGeneration string
|
||||
Transport Transport
|
||||
ConnectionID string
|
||||
Usable bool
|
||||
PhysicalActive bool
|
||||
Resource *Resource
|
||||
}
|
||||
|
||||
// HostPublication is the single live SFU ingress. TS: HostPublication.
|
||||
type HostPublication struct {
|
||||
Generation string
|
||||
HostSessionID string
|
||||
ConnectionID string
|
||||
Usable bool
|
||||
PhysicalActive bool
|
||||
Resource *Resource
|
||||
}
|
||||
|
||||
// ConnectionAttemptProgress is the NAT-prediction attempt counter
|
||||
// (Total is always 3). TS: ConnectionAttemptProgress.
|
||||
type ConnectionAttemptProgress struct {
|
||||
Current int
|
||||
Total int
|
||||
}
|
||||
|
||||
// CurrentAttempt is OperationSnapshot.current: the live candidate attempt.
|
||||
// ConnectionAttempt is nil when the attempt has no NAT opportunity.
|
||||
type CurrentAttempt struct {
|
||||
Tuple CandidateTuple
|
||||
Revision int64
|
||||
ConnectionID string
|
||||
ConnectionAttempt *ConnectionAttemptProgress
|
||||
}
|
||||
|
||||
// OperationSnapshot is a deep copy of the in-flight operation plus the
|
||||
// derived WakeAtMs. Current is nil without a live attempt. TS: OperationSnapshot.
|
||||
type OperationSnapshot struct {
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
DemandPeerID string
|
||||
Reason DemandReason
|
||||
BaseRevision int64
|
||||
FactVersion int64
|
||||
Candidates []CandidatePlan
|
||||
Cursor int
|
||||
DeadlineAtMs int64
|
||||
WakeAtMs int64
|
||||
Current *CurrentAttempt
|
||||
}
|
||||
|
||||
// RouteSnapshot is the committed graph as the router reads it.
|
||||
// UpstreamByViewer is a fresh insertion-ordered copy (child peerId -> edge);
|
||||
// HostPublication is a shallow copy or nil; Operation is nil when idle.
|
||||
// TS: RouteSnapshot.
|
||||
type RouteSnapshot struct {
|
||||
Revision int64
|
||||
Paused bool
|
||||
FactVersion int64
|
||||
UpstreamByViewer *ordered.Map[string, CommittedEdge]
|
||||
HostPublication *HostPublication
|
||||
Operation *OperationSnapshot
|
||||
}
|
||||
|
||||
// ReconcileResult is the scheduler outcome. TS: ReconcileResult.
|
||||
type ReconcileResult struct {
|
||||
Operation *OperationSnapshot
|
||||
RemovedPeerIDs []string
|
||||
FailedPeerIDs []string
|
||||
Released []*Resource
|
||||
}
|
||||
|
||||
// SettleResult is the outcome of a candidate settlement call. Committed is
|
||||
// true only when CandidateReady committed the attempt (TS: optional
|
||||
// committed, read as truthy by the router). TS: SettleResult.
|
||||
type SettleResult struct {
|
||||
Accepted bool
|
||||
Committed bool
|
||||
FailedPeerIDs []string
|
||||
ActiveRevision int64
|
||||
Released []*Resource
|
||||
}
|
||||
|
||||
// SenderQualityEvidenceResult is the outcome of the two sender-side
|
||||
// observers. TS declares it separately with a required committed flag; the
|
||||
// Go shape is identical to SettleResult.
|
||||
type SenderQualityEvidenceResult = SettleResult
|
||||
|
||||
// BeginResult is the outcome of the cursor-guarded candidate calls.
|
||||
// TS: BeginResult.
|
||||
type BeginResult struct {
|
||||
Accepted bool
|
||||
Operation *OperationSnapshot
|
||||
FailedPeerIDs []string
|
||||
Released []*Resource
|
||||
}
|
||||
|
||||
// ParticipantInput describes a connected participant. TS: ParticipantInput.
|
||||
type ParticipantInput struct {
|
||||
PeerID string
|
||||
Role protocol.Role
|
||||
SessionID string
|
||||
EffectiveDownstreamCapacity int
|
||||
}
|
||||
|
||||
// EdgeGuard identifies one committed edge exactly. ParentSessionID is only
|
||||
// compared for peer edges; callers pass the edge's own ParentSessionID for
|
||||
// those and leave it empty for SFU edges. TS: EdgeGuard.
|
||||
type EdgeGuard struct {
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
RouteRevision int64
|
||||
ConnectionID string
|
||||
ParentSessionID string
|
||||
}
|
||||
|
||||
// ParentEdgeGuard identifies a direct edge from the parent's side.
|
||||
// TS: the inline input of invalidateDirectEdgeFromParent.
|
||||
type ParentEdgeGuard struct {
|
||||
ParentPeerID string
|
||||
ParentSessionID string
|
||||
RouteRevision int64
|
||||
ConnectionID string
|
||||
}
|
||||
|
||||
// PublicationGuard identifies the Host publication exactly. TS: the inline
|
||||
// guard of invalidateHostPublication and retireHostPublication.
|
||||
type PublicationGuard struct {
|
||||
HostSessionID string
|
||||
RouteRevision int64
|
||||
Generation string
|
||||
ConnectionID string
|
||||
}
|
||||
|
||||
// AdoptDirectConnectionInput is an EdgeGuard plus the replacement
|
||||
// connection. TS: EdgeGuard & { newConnectionId }.
|
||||
type AdoptDirectConnectionInput struct {
|
||||
EdgeGuard
|
||||
NewConnectionID string
|
||||
}
|
||||
|
||||
// CandidateGuard identifies the live attempt exactly. TS: CandidateGuard.
|
||||
type CandidateGuard struct {
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
Revision int64
|
||||
ConnectionID string
|
||||
}
|
||||
|
||||
// CandidateCursorGuard identifies one candidate slot of the current
|
||||
// operation: it must still be at Cursor with an equal Plan, built at
|
||||
// FactVersion on BaseRevision. TS: CandidateCursorGuard.
|
||||
type CandidateCursorGuard struct {
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
BaseRevision int64
|
||||
FactVersion int64
|
||||
Cursor int
|
||||
Plan CandidatePlan
|
||||
}
|
||||
|
||||
// BeginInput is the BeginCurrentCandidate input. PublicationGeneration and
|
||||
// PublicationConnectionID are required for sfu create/replace tuples;
|
||||
// HostSessionID is required for every SFU tuple (empty = TS undefined,
|
||||
// rejected the same way). TS: the inline input of beginCurrentCandidate.
|
||||
type BeginInput struct {
|
||||
Guard CandidateCursorGuard
|
||||
NowMs int64
|
||||
ConnectionID string
|
||||
Reservation CandidateReservation
|
||||
PublicationGeneration string
|
||||
PublicationConnectionID string
|
||||
HostSessionID string
|
||||
}
|
||||
|
||||
// CandidateProof carries the Viewer's one-shot relative quality approval.
|
||||
// TS: the proof parameter of candidateReady ({ relativeQualityApproved? }).
|
||||
type CandidateProof struct {
|
||||
RelativeQualityApproved bool
|
||||
}
|
||||
|
||||
// QualityUpstream names the upstream a receiver-side window was measured
|
||||
// on: PeerID is set only for UpstreamPeer. TS: RouteQualityEvidenceInput.upstream.
|
||||
type QualityUpstream struct {
|
||||
Kind UpstreamKind
|
||||
PeerID string
|
||||
}
|
||||
|
||||
// RouteQualityEvidenceInput is one receiver-side quality window. Only
|
||||
// FramesDecodedDelta, FreezeCountDelta, FreezeDurationMsDelta,
|
||||
// PauseCountDelta and PauseDurationMsDelta of Metrics are read.
|
||||
// TS: RouteQualityEvidenceInput.
|
||||
type RouteQualityEvidenceInput struct {
|
||||
ChildPeerID string
|
||||
ChildSessionID string
|
||||
RouteRevision int64
|
||||
ConnectionID string
|
||||
Upstream QualityUpstream
|
||||
PresentationEpoch int64
|
||||
WindowMs int64
|
||||
Metrics protocol.ViewerQualityEvidenceMetrics
|
||||
AcceptedAtMs int64
|
||||
}
|
||||
|
||||
// SenderQualityEvidenceInput is one sender-side health sample from a parent
|
||||
// about one child. SenderIdentity and SampleTimestampMs are nullable.
|
||||
// TS: SenderQualityEvidenceInput.
|
||||
type SenderQualityEvidenceInput struct {
|
||||
ParentPeerID string
|
||||
ParentSessionID string
|
||||
ChildPeerID string
|
||||
RouteRevision int64
|
||||
ConnectionID string
|
||||
SenderIdentity *string
|
||||
SampleTimestampMs *int64
|
||||
State SenderQualityState
|
||||
AcceptedAtMs int64
|
||||
}
|
||||
|
||||
// SfuPublisherQualityEvidenceInput is one Host->SFU ingress health sample.
|
||||
// TS: SfuPublisherQualityEvidenceInput.
|
||||
type SfuPublisherQualityEvidenceInput struct {
|
||||
HostPeerID string
|
||||
HostSessionID string
|
||||
PublicationGeneration string
|
||||
RouteRevision int64
|
||||
State SenderQualityState
|
||||
SampleTimestampMs *int64
|
||||
AcceptedAtMs int64
|
||||
}
|
||||
|
||||
// Options configures a Controller. DebugRoomID empty disables the sanitised
|
||||
// route debug events. TS: ControllerOptions.
|
||||
type Options struct {
|
||||
HostPeerID string
|
||||
DebugRoomID string
|
||||
EndpointMediaCopyCapacity int
|
||||
OperationTimeoutMs int64
|
||||
SfuEnabled bool
|
||||
QualityConvergenceEnabled bool
|
||||
NatPredictionEnabled bool
|
||||
}
|
||||
|
||||
// Controller ports RoomRouteController. It never locks: the caller holds
|
||||
// signal.Server.mu around every call. The public methods live in the files
|
||||
// named after the TS helper clusters (controller.go, operation.go,
|
||||
// quality.go, ...); every signature below the types is unchanged.
|
||||
type Controller struct {
|
||||
// hostPeerID is its own mutable field because RebindHostIdentity
|
||||
// reassigns it (TS: options.hostPeerId = peerId at line 660); the other
|
||||
// options are copied once and never change.
|
||||
hostPeerID string
|
||||
debugRoomID string
|
||||
endpointMediaCopyCapacity int
|
||||
operationTimeoutMs int64
|
||||
sfuEnabled bool
|
||||
qualityConvergenceEnabled bool
|
||||
natPredictionEnabled bool
|
||||
|
||||
// Insertion order is load-bearing for participants, upstreamByViewer and
|
||||
// directContinuations (map §4.1-4.3); the opportunity ledgers and the
|
||||
// retiring set are ordered for the same delete-while-iterating semantics.
|
||||
participants ordered.Map[string, *participant]
|
||||
upstreamByViewer ordered.Map[string, *CommittedEdge]
|
||||
hostPublication *HostPublication
|
||||
operation *operation
|
||||
revision int64
|
||||
latestRevision int64
|
||||
factVersion int64
|
||||
nextJoinOrder int64
|
||||
paused bool
|
||||
routeTimings map[string]*routeTimingRecord
|
||||
qualityObservations map[string]*routeQualityObservation
|
||||
senderQualityObservations map[string]*senderQualityObservation
|
||||
senderQualityBaselines map[string]bool // tri-state: absent / false / true (read with comma-ok)
|
||||
senderQualityRegenerationBlocks map[string]senderQualityIdentity
|
||||
sfuPublisherQualityObservation *sfuPublisherQualityObservation
|
||||
qualityBaselinesPending map[string]struct{}
|
||||
directContinuations ordered.Map[string, *directContinuation]
|
||||
retiringPublicationGenerations ordered.Map[string, struct{}]
|
||||
sfuBootstrapIntent *sfuBootstrapIntent
|
||||
consumedSfuBootstrapOpportunities ordered.Map[string, int]
|
||||
rootConvergenceRootPeerID string // "" = TS undefined
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// buildCandidates ports 2767: the full plan list filtered by the
|
||||
// availability opportunity ledger.
|
||||
func (c *Controller) buildCandidates(childPeerID string, sfuOnly bool) []CandidatePlan {
|
||||
plans := c.buildCandidatePlans(childPeerID, sfuOnly, true)
|
||||
out := make([]CandidatePlan, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
if c.candidateOpportunityAvailable(childPeerID, plan) {
|
||||
out = append(out, plan)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildCandidatePlans ports 2777: eligible parents sorted by (untried
|
||||
// attempts when preferUntried) then depth, remaining capacity descending,
|
||||
// stablePairRank, compareParticipant; the SFU tuple is spliced into index 1
|
||||
// (§4.4 #28) and the list is deduped by tupleKey (§4.4 #29).
|
||||
func (c *Controller) buildCandidatePlans(childPeerID string, sfuOnly, preferUntried bool) []CandidatePlan {
|
||||
descendants := c.descendantsOf(childPeerID)
|
||||
parents := []*participant{}
|
||||
for _, parent := range c.participants.Values() {
|
||||
if parent.sessionID != "" && !parent.departureConfirmed && parent.peerID != childPeerID &&
|
||||
!slices.Contains(descendants, parent.peerID) && c.sourceUsable(parent.peerID) && c.targetFits(parent.peerID, childPeerID) {
|
||||
parents = append(parents, parent)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(parents, func(i, j int) bool {
|
||||
left, right := parents[i], parents[j]
|
||||
if preferUntried {
|
||||
if d := c.peerAttemptsStarted(childPeerID, left.peerID) - c.peerAttemptsStarted(childPeerID, right.peerID); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
}
|
||||
if d := c.depth(left.peerID) - c.depth(right.peerID); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
if d := c.remaining(right.peerID, childPeerID) - c.remaining(left.peerID, childPeerID); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
if d := stablePairRank(childPeerID, left.peerID) - stablePairRank(childPeerID, right.peerID); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
return compareParticipant(left, right) < 0
|
||||
})
|
||||
directCandidates := make([]CandidateTuple, 0, len(parents))
|
||||
for _, parent := range parents {
|
||||
directCandidates = append(directCandidates, CandidateTuple{Kind: UpstreamPeer, ParentPeerID: parent.peerID, Transport: TransportDirect})
|
||||
}
|
||||
var sfuCandidate *CandidateTuple
|
||||
if c.sfuEnabled {
|
||||
if c.hostPublication != nil && c.hostPublication.Usable && c.hostPublication.PhysicalActive {
|
||||
sfuCandidate = &CandidateTuple{Kind: UpstreamSfu, Publication: PublicationReuse}
|
||||
} else if c.hostPublication != nil || c.publicationFits(childPeerID, false) {
|
||||
publication := PublicationCreate
|
||||
if c.hostPublication != nil {
|
||||
publication = PublicationReplace
|
||||
}
|
||||
sfuCandidate = &CandidateTuple{Kind: UpstreamSfu, Publication: publication}
|
||||
}
|
||||
}
|
||||
var candidates []CandidateTuple
|
||||
switch {
|
||||
case sfuOnly:
|
||||
if sfuCandidate != nil {
|
||||
candidates = []CandidateTuple{*sfuCandidate}
|
||||
}
|
||||
case sfuCandidate != nil && len(directCandidates) > 0:
|
||||
candidates = append(candidates, directCandidates[0], *sfuCandidate)
|
||||
candidates = append(candidates, directCandidates[1:]...)
|
||||
case sfuCandidate != nil:
|
||||
candidates = []CandidateTuple{*sfuCandidate}
|
||||
default:
|
||||
candidates = directCandidates
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
plans := make([]CandidatePlan, 0, len(candidates))
|
||||
for _, tuple := range candidates {
|
||||
key := tupleKey(tuple)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
plan := c.planCandidate(childPeerID, tuple)
|
||||
if plan != nil && c.candidateValid(childPeerID, *plan, nil) {
|
||||
plans = append(plans, *plan)
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
// planCandidate ports 3947: decides none / overlap / bounded-gap from the
|
||||
// producer's copy budget; nil when the producer is gone or over budget.
|
||||
func (c *Controller) planCandidate(childPeerID string, tuple CandidateTuple) *CandidatePlan {
|
||||
if tuple.Kind == UpstreamSfu && tuple.Publication == PublicationReuse {
|
||||
// No producer on sfu/reuse (3949); endpointTransitionEquals compares it.
|
||||
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionNone}}
|
||||
}
|
||||
producerPeerID := c.hostPeerID
|
||||
if tuple.Kind == UpstreamPeer {
|
||||
producerPeerID = tuple.ParentPeerID
|
||||
}
|
||||
producer, _ := c.participants.Get(producerPeerID)
|
||||
if producer == nil || producer.sessionID == "" || producer.departureConfirmed {
|
||||
return nil
|
||||
}
|
||||
copies := c.physicalCopies(producerPeerID)
|
||||
producer0 := producerPeerID
|
||||
if copies+1 <= producer.effectiveDownstreamCapacity {
|
||||
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionNone, ProducerPeerID: &producer0}}
|
||||
}
|
||||
if copies+1 <= protocol.EndpointMediaCopyLimit(c.endpointMediaCopyCapacity, protocol.EndpointMediaCopyTransition) {
|
||||
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionOverlap, ProducerPeerID: &producer0}}
|
||||
}
|
||||
retire := c.retirementFor(childPeerID, producerPeerID, tuple)
|
||||
if retire == nil || copies > producer.effectiveDownstreamCapacity {
|
||||
return nil
|
||||
}
|
||||
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionBoundedGap, ProducerPeerID: &producer0, Retire: retire}}
|
||||
}
|
||||
|
||||
// retirementFor ports 3969.
|
||||
func (c *Controller) retirementFor(childPeerID, producerPeerID string, tuple CandidateTuple) *EndpointRetirement {
|
||||
old, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
if old != nil && old.Kind == UpstreamPeer && old.ParentPeerID == producerPeerID && old.PhysicalActive {
|
||||
return &EndpointRetirement{
|
||||
Kind: RetireEdge,
|
||||
ChildPeerID: childPeerID,
|
||||
ChildSessionID: old.ChildSessionID,
|
||||
ParentPeerID: old.ParentPeerID,
|
||||
ParentSessionID: old.ParentSessionID,
|
||||
Transport: old.Transport,
|
||||
ConnectionID: old.ConnectionID,
|
||||
}
|
||||
}
|
||||
if producerPeerID != c.hostPeerID || c.hostPublication == nil || !c.hostPublication.PhysicalActive {
|
||||
return nil
|
||||
}
|
||||
replacesPublication := tuple.Kind == UpstreamSfu && tuple.Publication == PublicationReplace
|
||||
releasesLastPublication := tuple.Kind == UpstreamPeer && old != nil && old.Kind == UpstreamSfu && old.PhysicalActive &&
|
||||
c.sfuSubscriberCount() == 1
|
||||
if !replacesPublication && !releasesLastPublication {
|
||||
return nil
|
||||
}
|
||||
return &EndpointRetirement{
|
||||
Kind: RetirePublication,
|
||||
HostSessionID: c.hostPublication.HostSessionID,
|
||||
Generation: c.hostPublication.Generation,
|
||||
ConnectionID: c.hostPublication.ConnectionID,
|
||||
}
|
||||
}
|
||||
|
||||
// candidateValid ports 3903; att is the live attempt when validating it.
|
||||
func (c *Controller) candidateValid(childPeerID string, plan CandidatePlan, att *attempt) bool {
|
||||
tuple := plan.Tuple
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
if child == nil || child.sessionID == "" || child.departureConfirmed || (att != nil && att.childSessionID != child.sessionID) {
|
||||
return false
|
||||
}
|
||||
if tuple.Kind == UpstreamPeer {
|
||||
parent, _ := c.participants.Get(tuple.ParentPeerID)
|
||||
if parent == nil || parent.sessionID == "" || parent.departureConfirmed || (att != nil && parent.sessionID != att.parentSessionID) ||
|
||||
slices.Contains(c.descendantsOf(childPeerID), parent.peerID) || !c.sourceUsable(parent.peerID) ||
|
||||
!c.targetFits(parent.peerID, childPeerID) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if !c.sfuEnabled {
|
||||
return false
|
||||
}
|
||||
if att != nil && att.hostSessionID != "" {
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
if host == nil || !host.sessionIs(att.hostSessionID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch tuple.Publication {
|
||||
case PublicationReuse:
|
||||
if c.hostPublication == nil || !c.hostPublication.Usable || !c.hostPublication.PhysicalActive ||
|
||||
(att != nil && att.publicationGeneration != c.hostPublication.Generation) {
|
||||
return false
|
||||
}
|
||||
case PublicationReplace:
|
||||
if c.hostPublication == nil || c.hostPublication.Usable || !c.publicationFits(childPeerID, true) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if c.hostPublication != nil || !c.publicationFits(childPeerID, false) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPlan := c.planCandidate(childPeerID, tuple)
|
||||
return currentPlan != nil && endpointTransitionEquals(currentPlan.EndpointTransition, plan.EndpointTransition)
|
||||
}
|
||||
|
||||
// operationCandidateValid ports 3934.
|
||||
func (c *Controller) operationCandidateValid(op *operation, plan CandidatePlan, att *attempt) bool {
|
||||
return c.candidateValid(op.childPeerID, plan, att) &&
|
||||
((att != nil && att.connectionAttempt != nil) ||
|
||||
!isAvailabilityOperation(op.reason) ||
|
||||
c.candidateOpportunityAvailable(op.childPeerID, plan))
|
||||
}
|
||||
|
||||
// physicalCopies ports 3999.
|
||||
func (c *Controller) physicalCopies(peerID string) int {
|
||||
copies := 0
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind == UpstreamPeer && edge.ParentPeerID == peerID && edge.PhysicalActive {
|
||||
copies++
|
||||
}
|
||||
}
|
||||
if peerID == c.hostPeerID && c.hostPublication != nil && c.hostPublication.PhysicalActive {
|
||||
copies++
|
||||
}
|
||||
return copies
|
||||
}
|
||||
|
||||
// assertReservation ports 4006: panics on a reservation kind that does not
|
||||
// match the tuple or a missing overlap for an overlap transition.
|
||||
func assertReservation(tuple CandidateTuple, reservation CandidateReservation, overlap bool) {
|
||||
expected := ReservationSfuCreate
|
||||
switch {
|
||||
case tuple.Kind == UpstreamPeer:
|
||||
expected = ReservationDirect
|
||||
case tuple.Publication == PublicationReuse:
|
||||
expected = ReservationSfuReuse
|
||||
}
|
||||
if reservation.Kind != expected {
|
||||
panic(errors.New("Candidate reservation kind does not match tuple"))
|
||||
}
|
||||
if overlap && reservation.Overlap == nil {
|
||||
panic(errors.New("Candidate requires an endpoint overlap reservation"))
|
||||
}
|
||||
}
|
||||
|
||||
// targetFits ports 4380; the parent must exist.
|
||||
func (c *Controller) targetFits(parentPeerID, childPeerID string) bool {
|
||||
parent, _ := c.participants.Get(parentPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
already := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID && edge.PhysicalActive
|
||||
releasesLastPublication := parent.role == protocol.RoleHost && edge != nil && edge.Kind == UpstreamSfu && edge.PhysicalActive &&
|
||||
c.sfuSubscriberCount() == 1
|
||||
slots := c.usedSlots(parentPeerID)
|
||||
if releasesLastPublication {
|
||||
slots--
|
||||
}
|
||||
if !already {
|
||||
slots++
|
||||
}
|
||||
return slots <= parent.effectiveDownstreamCapacity
|
||||
}
|
||||
|
||||
// publicationFits ports 4390.
|
||||
func (c *Controller) publicationFits(childPeerID string, replacing bool) bool {
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
releasesHost := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == c.hostPeerID && edge.PhysicalActive
|
||||
replacesPhysicalPublication := replacing && c.hostPublication != nil && c.hostPublication.PhysicalActive
|
||||
nextSlots := c.usedSlots(c.hostPeerID)
|
||||
if releasesHost {
|
||||
nextSlots--
|
||||
}
|
||||
if !replacesPhysicalPublication {
|
||||
nextSlots++
|
||||
}
|
||||
return host != nil && host.sessionID != "" && !host.departureConfirmed && nextSlots <= host.effectiveDownstreamCapacity
|
||||
}
|
||||
|
||||
// hostHasPublicationSlot ports 4400.
|
||||
func (c *Controller) hostHasPublicationSlot() bool {
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
return host != nil && c.usedSlots(c.hostPeerID)+1 <= host.effectiveDownstreamCapacity
|
||||
}
|
||||
|
||||
func (c *Controller) usedSlots(peerID string) int {
|
||||
return c.physicalCopies(peerID)
|
||||
}
|
||||
|
||||
// remaining ports 4409 and may be negative; the parent must exist.
|
||||
func (c *Controller) remaining(parentPeerID, childPeerID string) int64 {
|
||||
parent, _ := c.participants.Get(parentPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
already := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID && edge.PhysicalActive
|
||||
value := int64(parent.effectiveDownstreamCapacity - c.usedSlots(parentPeerID))
|
||||
if !already {
|
||||
value--
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// depth ports 4416; a cycle yields MAX_SAFE_INTEGER (not MaxInt64) so the
|
||||
// comparator subtraction cannot overflow.
|
||||
func (c *Controller) depth(peerID string) int64 {
|
||||
if peerID == c.hostPeerID {
|
||||
return 1
|
||||
}
|
||||
depth := int64(1)
|
||||
seen := make(map[string]struct{})
|
||||
for {
|
||||
if _, visited := seen[peerID]; visited {
|
||||
return maxSafeInteger
|
||||
}
|
||||
seen[peerID] = struct{}{}
|
||||
edge, _ := c.upstreamByViewer.Get(peerID)
|
||||
if edge == nil || edge.Kind == UpstreamSfu {
|
||||
return depth + 1
|
||||
}
|
||||
depth++
|
||||
if edge.ParentPeerID == c.hostPeerID {
|
||||
return depth
|
||||
}
|
||||
peerID = edge.ParentPeerID
|
||||
}
|
||||
}
|
||||
|
||||
// childrenOf ports 4352: direct peer children in upstreamByViewer order
|
||||
// (§4.2 #17), the base order for every children-derived list.
|
||||
func (c *Controller) childrenOf(parentPeerID string) []string {
|
||||
children := []string{}
|
||||
for childPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID {
|
||||
children = append(children, childPeerID)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// descendantsOf ports 4356: a DFS (the TS queue is popped from the end,
|
||||
// §4.5 #49) returning the visit order without duplicates.
|
||||
func (c *Controller) descendantsOf(peerID string) []string {
|
||||
descendants := []string{}
|
||||
seen := make(map[string]struct{})
|
||||
stack := []string{peerID}
|
||||
for len(stack) > 0 {
|
||||
last := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
for _, child := range c.childrenOf(last) {
|
||||
if _, visited := seen[child]; visited {
|
||||
continue
|
||||
}
|
||||
seen[child] = struct{}{}
|
||||
descendants = append(descendants, child)
|
||||
stack = append(stack, child)
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
// withSelf ports `this.descendantsOf(peerId).add(peerId)`: the peer itself
|
||||
// is appended last unless the walk already reached it (§4.5 #50).
|
||||
func withSelf(descendants []string, peerID string) []string {
|
||||
if slices.Contains(descendants, peerID) {
|
||||
return descendants
|
||||
}
|
||||
return append(descendants, peerID)
|
||||
}
|
||||
|
||||
// sourceUsable ports 4365: the whole upstream path is usable and active.
|
||||
func (c *Controller) sourceUsable(peerID string) bool {
|
||||
if peerID == c.hostPeerID {
|
||||
return true
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for {
|
||||
if _, visited := seen[peerID]; visited {
|
||||
return false
|
||||
}
|
||||
seen[peerID] = struct{}{}
|
||||
edge, _ := c.upstreamByViewer.Get(peerID)
|
||||
if edge == nil || !edge.Usable || !edge.PhysicalActive {
|
||||
return false
|
||||
}
|
||||
if edge.Kind == UpstreamSfu {
|
||||
return c.hostPublication != nil && c.hostPublication.Usable &&
|
||||
c.hostPublication.PhysicalActive && c.hostPublication.Generation == edge.PublicationGeneration
|
||||
}
|
||||
if edge.ParentPeerID == c.hostPeerID {
|
||||
return true
|
||||
}
|
||||
peerID = edge.ParentPeerID
|
||||
}
|
||||
}
|
||||
|
||||
// participantBlocked ports 3862.
|
||||
func (c *Controller) participantBlocked(current *participant) bool {
|
||||
return current.availabilityExhausted && len(c.buildCandidates(current.peerID, false)) == 0
|
||||
}
|
||||
|
||||
// availableViewers ports 3869: connected, non-departed viewers sorted by
|
||||
// compareParticipant (a total order, so the map order does not leak).
|
||||
func (c *Controller) availableViewers(includeBlocked bool) []*participant {
|
||||
viewers := []*participant{}
|
||||
for _, current := range c.participants.Values() {
|
||||
if current.role == protocol.RoleViewer && current.sessionID != "" && !current.departureConfirmed &&
|
||||
(includeBlocked || !c.participantBlocked(current)) {
|
||||
viewers = append(viewers, current)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(viewers, func(i, j int) bool { return compareParticipant(viewers[i], viewers[j]) < 0 })
|
||||
return viewers
|
||||
}
|
||||
|
||||
// overflowChildren ports 3876: per parent in participants order, the
|
||||
// overflowing children newest-first (§4.1 #3).
|
||||
func (c *Controller) overflowChildren() []string {
|
||||
result := []string{}
|
||||
for _, parent := range c.participants.Values() {
|
||||
overflow := []string{}
|
||||
for _, id := range c.overflowPeerChildren(parent.peerID) {
|
||||
child, _ := c.participants.Get(id)
|
||||
if child != nil && child.sessionID != "" && !child.departureConfirmed && !c.participantBlocked(child) {
|
||||
overflow = append(overflow, id)
|
||||
}
|
||||
}
|
||||
slices.Reverse(overflow)
|
||||
result = append(result, overflow...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// overflowPeerChildren ports 3889: the physically active direct children
|
||||
// beyond the parent's capacity, oldest first kept.
|
||||
func (c *Controller) overflowPeerChildren(parentPeerID string) []string {
|
||||
parent, _ := c.participants.Get(parentPeerID)
|
||||
if parent == nil {
|
||||
return []string{}
|
||||
}
|
||||
publicationCopies := 0
|
||||
if parent.role == protocol.RoleHost && c.hostPublication != nil && c.hostPublication.PhysicalActive {
|
||||
publicationCopies = 1
|
||||
}
|
||||
capacity := max(0, parent.effectiveDownstreamCapacity-publicationCopies)
|
||||
children := []string{}
|
||||
for _, id := range c.childrenOf(parentPeerID) {
|
||||
if edge, ok := c.upstreamByViewer.Get(id); ok && edge.Kind == UpstreamPeer && edge.PhysicalActive {
|
||||
children = append(children, id)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(children, func(i, j int) bool {
|
||||
left, _ := c.participants.Get(children[i])
|
||||
right, _ := c.participants.Get(children[j])
|
||||
return compareParticipant(left, right) < 0
|
||||
})
|
||||
if capacity >= len(children) {
|
||||
return []string{}
|
||||
}
|
||||
return children[capacity:]
|
||||
}
|
||||
|
||||
// tupleKey ports 4979.
|
||||
func tupleKey(tuple CandidateTuple) string {
|
||||
if tuple.Kind == UpstreamPeer {
|
||||
key := "peer:" + tuple.ParentPeerID
|
||||
if tuple.Regenerate {
|
||||
key += ":regenerate"
|
||||
}
|
||||
return key
|
||||
}
|
||||
return "sfu:" + string(tuple.Publication)
|
||||
}
|
||||
|
||||
// edgeTupleKey ports 4999.
|
||||
func edgeTupleKey(edge *CommittedEdge) string {
|
||||
if edge.Kind == UpstreamPeer {
|
||||
return "peer:" + edge.ParentPeerID
|
||||
}
|
||||
return "sfu:reuse"
|
||||
}
|
||||
|
||||
// endpointTransitionRank ports 5003.
|
||||
func endpointTransitionRank(transition EndpointTransition) int {
|
||||
switch transition.Kind {
|
||||
case TransitionNone:
|
||||
return 0
|
||||
case TransitionOverlap:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// cloneCandidatePlan ports 5020 (deep copy of the retirement).
|
||||
func cloneCandidatePlan(plan CandidatePlan) CandidatePlan {
|
||||
clone := plan
|
||||
if plan.EndpointTransition.ProducerPeerID != nil {
|
||||
producer := *plan.EndpointTransition.ProducerPeerID
|
||||
clone.EndpointTransition.ProducerPeerID = &producer
|
||||
}
|
||||
if plan.EndpointTransition.Retire != nil {
|
||||
retire := *plan.EndpointTransition.Retire
|
||||
clone.EndpointTransition.Retire = &retire
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// candidatePlanEquals ports 5029.
|
||||
func candidatePlanEquals(left, right CandidatePlan) bool {
|
||||
return tupleKey(left.Tuple) == tupleKey(right.Tuple) &&
|
||||
endpointTransitionEquals(left.EndpointTransition, right.EndpointTransition)
|
||||
}
|
||||
|
||||
func stringPtrEquals(left, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
// endpointTransitionEquals ports 5034; `undefined !== HOST` matters for the
|
||||
// none producer, hence the pointer comparison.
|
||||
func endpointTransitionEquals(left, right EndpointTransition) bool {
|
||||
if left.Kind != right.Kind {
|
||||
return false
|
||||
}
|
||||
if left.Kind == TransitionNone || left.Kind == TransitionOverlap {
|
||||
return stringPtrEquals(left.ProducerPeerID, right.ProducerPeerID)
|
||||
}
|
||||
if left.Kind != TransitionBoundedGap || !stringPtrEquals(left.ProducerPeerID, right.ProducerPeerID) ||
|
||||
left.Retire == nil || right.Retire == nil || left.Retire.Kind != right.Retire.Kind {
|
||||
return false
|
||||
}
|
||||
if left.Retire.Kind == RetirePublication {
|
||||
return left.Retire.HostSessionID == right.Retire.HostSessionID &&
|
||||
left.Retire.Generation == right.Retire.Generation &&
|
||||
left.Retire.ConnectionID == right.Retire.ConnectionID
|
||||
}
|
||||
if left.Retire.Kind == RetireEdge {
|
||||
return left.Retire.ChildPeerID == right.Retire.ChildPeerID &&
|
||||
left.Retire.ChildSessionID == right.Retire.ChildSessionID &&
|
||||
left.Retire.ParentPeerID == right.Retire.ParentPeerID &&
|
||||
left.Retire.ParentSessionID == right.Retire.ParentSessionID &&
|
||||
left.Retire.Transport == right.Retire.Transport &&
|
||||
left.Retire.ConnectionID == right.Retire.ConnectionID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reservationResources ports 5060 in order: edge (never for a borrowed
|
||||
// sfu-reuse edge, which belongs to another holder), publication, overlap.
|
||||
func reservationResources(reservation CandidateReservation) []*Resource {
|
||||
resources := []*Resource{}
|
||||
if reservation.Kind != ReservationDirect && !(reservation.Kind == ReservationSfuReuse && reservation.Borrowed) {
|
||||
resources = append(resources, reservation.Edge)
|
||||
}
|
||||
if reservation.Kind == ReservationSfuCreate {
|
||||
resources = append(resources, reservation.Publication)
|
||||
}
|
||||
if reservation.Overlap != nil {
|
||||
resources = append(resources, reservation.Overlap)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// File-local constants of room-route-controller.ts (lines 112-114).
|
||||
const (
|
||||
maxDirectHeadStartMs int64 = 5_000
|
||||
maxNatConnectionAttempts = 3
|
||||
sfuOpportunityPrefix = "sfu\x00"
|
||||
maxSafeInteger int64 = protocol.MaxSafeInteger
|
||||
qualityEvidenceExpiryMs int64 = protocol.ViewerQualityEvidenceExpiryMs
|
||||
persistentDegradedWindows = int64(protocol.PersistentNativeEdgeDegradedWindows)
|
||||
)
|
||||
|
||||
// candidateOpportunity is one consumed-ledger entry (TS CandidateOpportunity).
|
||||
type candidateOpportunity struct {
|
||||
rank int
|
||||
startedAttempts int
|
||||
exhausted bool
|
||||
}
|
||||
|
||||
// participant is a known peer (TS Participant). sessionID "" is the TS
|
||||
// null: disconnected but in grace, or departed. Wire session IDs are
|
||||
// validated opaque IDs, so an empty string never names a live session;
|
||||
// bootstrapFailureReported and sfuFirstAtNextRoute are TS `true | undefined`
|
||||
// and only ever compared to true, so a bool is exact.
|
||||
type participant struct {
|
||||
peerID string
|
||||
role protocol.Role
|
||||
sessionID string
|
||||
departureConfirmed bool
|
||||
joinOrder int64
|
||||
effectiveDownstreamCapacity int
|
||||
availabilityExhausted bool
|
||||
consumedCandidateOpportunities ordered.Map[string, candidateOpportunity]
|
||||
bootstrapFailureReported bool
|
||||
sfuFirstAtNextRoute bool
|
||||
}
|
||||
|
||||
// sessionIs ports `participant.sessionId === id`: a disconnected
|
||||
// participant (TS null) never equals any session string.
|
||||
func (p *participant) sessionIs(id string) bool {
|
||||
return p.sessionID != "" && p.sessionID == id
|
||||
}
|
||||
|
||||
// senderQualityIdentity is TS SenderQualityIdentity.
|
||||
type senderQualityIdentity struct {
|
||||
childSessionID string
|
||||
parentPeerID string
|
||||
parentSessionID string
|
||||
connectionID string
|
||||
senderIdentity string
|
||||
}
|
||||
|
||||
// attempt is the live candidate (TS Attempt). The optional strings use ""
|
||||
// for undefined (they are only ever set to non-empty values or compared
|
||||
// after a truthiness check); the optional timestamps are pointers because
|
||||
// 0 is a legal accepted/sample time and `!== undefined` is semantic.
|
||||
type attempt struct {
|
||||
tuple CandidateTuple
|
||||
revision int64
|
||||
connectionID string
|
||||
childSessionID string
|
||||
parentSessionID string
|
||||
hostSessionID string
|
||||
publicationGeneration string
|
||||
publicationConnectionID string
|
||||
reservation CandidateReservation
|
||||
startedAtMs int64
|
||||
transportConnected bool
|
||||
mediaReady bool
|
||||
connectionAttempt *ConnectionAttemptProgress
|
||||
senderQualityState SenderQualityState // "" = undefined
|
||||
senderQualityAcceptedAtMs *int64
|
||||
senderQualityConsecutiveHealthyWindows int64 // read only through `?? 0`
|
||||
senderQualityConsecutiveDegradedWindows int64 // read only through `?? 0`
|
||||
senderQualitySampleTimestampMs *int64
|
||||
senderQualityIdentity string // "" = undefined
|
||||
relativeQualityApprovedAtMs *int64
|
||||
}
|
||||
|
||||
// operation is the single in-flight route operation (TS ChildOperation).
|
||||
type operation struct {
|
||||
childPeerID string
|
||||
childSessionID string
|
||||
demandPeerID string
|
||||
demandSessionID string
|
||||
reason DemandReason
|
||||
baseRevision int64
|
||||
candidates []CandidatePlan
|
||||
cursor int
|
||||
deadlineAtMs int64
|
||||
builtAtFactVersion int64
|
||||
deferredParentPeerIDs []string
|
||||
peerAttemptStarted bool
|
||||
qualitySource *senderQualityIdentity
|
||||
current *attempt
|
||||
}
|
||||
|
||||
// planAt ports `operation.candidates[index]`: nil when out of range. It
|
||||
// returns a copy because the candidate array is spliced in place and TS
|
||||
// plan objects are never mutated after construction.
|
||||
func (o *operation) planAt(index int) *CandidatePlan {
|
||||
if index < 0 || index >= len(o.candidates) {
|
||||
return nil
|
||||
}
|
||||
plan := o.candidates[index]
|
||||
return &plan
|
||||
}
|
||||
|
||||
// planAtOrLast ports `operation.candidates[Math.min(cursor, length - 1)]`,
|
||||
// which is undefined (nil) for an empty array (map R8).
|
||||
func (o *operation) planAtOrLast() *CandidatePlan {
|
||||
return o.planAt(min(o.cursor, len(o.candidates)-1))
|
||||
}
|
||||
|
||||
// currentTuple ports `operation.current?.tuple ?? operation.candidates[operation.cursor]?.tuple`.
|
||||
func (o *operation) currentTuple() *CandidateTuple {
|
||||
if o.current != nil {
|
||||
tuple := o.current.tuple
|
||||
return &tuple
|
||||
}
|
||||
if plan := o.planAt(o.cursor); plan != nil {
|
||||
return &plan.Tuple
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// directContinuation is TS DirectContinuation; parentPeerIDs is the retry
|
||||
// order and is spliced in place.
|
||||
type directContinuation struct {
|
||||
childSessionID string
|
||||
sfuConnectionID string
|
||||
publicationGeneration string
|
||||
parentPeerIDs []string
|
||||
}
|
||||
|
||||
type sfuBootstrapIntent struct {
|
||||
demandPeerID string
|
||||
demandSessionID string
|
||||
}
|
||||
|
||||
type sfuBootstrapCarrier struct {
|
||||
demandPeerID string
|
||||
demandSessionID string
|
||||
carrierPeerID string
|
||||
}
|
||||
|
||||
// routeTimingRecord is TS RouteTimingRecord; the optional timestamps are
|
||||
// pointers (undefined = never happened, distinct from 0).
|
||||
type routeTimingRecord struct {
|
||||
demandAtMs int64
|
||||
reason DemandReason
|
||||
operationStartedAtMs *int64
|
||||
candidateStartedAtMs *int64
|
||||
firstDecodedFrameAtMs *int64
|
||||
finalAtMs *int64
|
||||
finalRoute FinalRoute
|
||||
rejectionBucket RejectionBucket
|
||||
}
|
||||
|
||||
// routeQualityObservation is TS RouteQualityObservation. upstreamPeerID is
|
||||
// "" for the sfu upstream (TS null); lastDecodedProgressAtMs nil is TS null.
|
||||
type routeQualityObservation struct {
|
||||
childSessionID string
|
||||
upstreamKind UpstreamKind
|
||||
upstreamPeerID string
|
||||
connectionID string
|
||||
presentationEpoch int64
|
||||
eligibleWindows int64
|
||||
eligibleDurationMs int64
|
||||
freezeWindows int64
|
||||
freezeCount int64
|
||||
freezeDurationMs int64
|
||||
pauseCount int64
|
||||
pauseDurationMs int64
|
||||
lastAcceptedAtMs int64
|
||||
lastDecodedProgressAtMs *int64
|
||||
}
|
||||
|
||||
// senderQualityObservation is TS SenderQualityObservation. The consumed
|
||||
// ledger is a pointer because `{...previous}` copies carry the same Map.
|
||||
type senderQualityObservation struct {
|
||||
childSessionID string
|
||||
parentPeerID string
|
||||
parentSessionID string
|
||||
connectionID string
|
||||
senderIdentity string
|
||||
state SenderQualityState
|
||||
consecutiveDegradedWindows int64
|
||||
lastSampleTimestampMs int64
|
||||
lastAcceptedAtMs int64
|
||||
consumedQualityCandidates *ordered.Map[string, int]
|
||||
}
|
||||
|
||||
type sfuPublisherQualityObservation struct {
|
||||
hostSessionID string
|
||||
publicationGeneration string
|
||||
state SenderQualityState
|
||||
consecutiveHealthyWindows int64
|
||||
lastSampleTimestampMs int64
|
||||
lastAcceptedAtMs int64
|
||||
}
|
||||
|
||||
// resourceSet is a JS Set<Resource>: pointer identity plus insertion order.
|
||||
type resourceSet struct {
|
||||
seen map[*Resource]struct{}
|
||||
list []*Resource
|
||||
}
|
||||
|
||||
func (s *resourceSet) add(resources ...*Resource) {
|
||||
if s.seen == nil {
|
||||
s.seen = make(map[*Resource]struct{})
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if _, ok := s.seen[resource]; ok {
|
||||
continue
|
||||
}
|
||||
s.seen[resource] = struct{}{}
|
||||
s.list = append(s.list, resource)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *resourceSet) has(resource *Resource) bool {
|
||||
_, ok := s.seen[resource]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resources returns a fresh slice in insertion order (`[...set]`).
|
||||
func (s *resourceSet) resources() []*Resource {
|
||||
return append([]*Resource{}, s.list...)
|
||||
}
|
||||
|
||||
// concatResources ports `[...a, ...b]` into a fresh, never-nil slice.
|
||||
func concatResources(parts ...[]*Resource) []*Resource {
|
||||
out := []*Resource{}
|
||||
for _, part := range parts {
|
||||
out = append(out, part...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeStrings ports `[...new Set(values)]`: first occurrence wins.
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// New ports the constructor: panics with "Endpoint media copy capacity must
|
||||
// be 1, 2, or 3" or "Route operation timeout must be a positive integer".
|
||||
func New(options Options) *Controller {
|
||||
if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !isSafeInteger(options.OperationTimeoutMs) || options.OperationTimeoutMs <= 0 {
|
||||
panic(errors.New("Route operation timeout must be a positive integer"))
|
||||
}
|
||||
c := &Controller{
|
||||
hostPeerID: options.HostPeerID,
|
||||
debugRoomID: options.DebugRoomID,
|
||||
endpointMediaCopyCapacity: options.EndpointMediaCopyCapacity,
|
||||
operationTimeoutMs: options.OperationTimeoutMs,
|
||||
sfuEnabled: options.SfuEnabled,
|
||||
qualityConvergenceEnabled: options.QualityConvergenceEnabled,
|
||||
natPredictionEnabled: options.NatPredictionEnabled,
|
||||
routeTimings: make(map[string]*routeTimingRecord),
|
||||
qualityObservations: make(map[string]*routeQualityObservation),
|
||||
senderQualityObservations: make(map[string]*senderQualityObservation),
|
||||
senderQualityBaselines: make(map[string]bool),
|
||||
senderQualityRegenerationBlocks: make(map[string]senderQualityIdentity),
|
||||
qualityBaselinesPending: make(map[string]struct{}),
|
||||
}
|
||||
c.debug("controller-created",
|
||||
"endpointCapacity", options.EndpointMediaCopyCapacity,
|
||||
"operationTimeoutMs", options.OperationTimeoutMs,
|
||||
"sfuEnabled", options.SfuEnabled,
|
||||
"qualityConvergenceEnabled", options.QualityConvergenceEnabled)
|
||||
return c
|
||||
}
|
||||
|
||||
// DiagnosticParticipantLabel returns "host", "viewer-<joinOrder>" or
|
||||
// "viewer-unknown" for a peer ID without exposing the ID.
|
||||
func (c *Controller) DiagnosticParticipantLabel(peerID string) string {
|
||||
return c.debugPeer(peerID)
|
||||
}
|
||||
|
||||
// Revision is Snapshot().Revision without the graph copy, for the callers
|
||||
// that only compare revisions before and after a mutation.
|
||||
func (c *Controller) Revision() int64 { return c.revision }
|
||||
|
||||
// Operation is Snapshot().Operation without the graph copy: a deep copy of
|
||||
// the in-flight operation, or nil when the controller is idle.
|
||||
func (c *Controller) Operation() *OperationSnapshot { return c.operationSnapshot() }
|
||||
|
||||
// Publication is Snapshot().HostPublication without the graph copy: a copy
|
||||
// of the single live SFU ingress, or nil when there is none.
|
||||
func (c *Controller) Publication() *HostPublication {
|
||||
if c.hostPublication == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *c.hostPublication
|
||||
return &copied
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the committed graph, the publication and the
|
||||
// operation snapshot; it mutates nothing.
|
||||
func (c *Controller) Snapshot() RouteSnapshot {
|
||||
// Ordering site: the router iterates this map to build assignments (§4.2 #9).
|
||||
upstream := &ordered.Map[string, CommittedEdge]{}
|
||||
for id, edge := range c.upstreamByViewer.All() {
|
||||
upstream.Set(id, *edge)
|
||||
}
|
||||
var publication *HostPublication
|
||||
if c.hostPublication != nil {
|
||||
copied := *c.hostPublication
|
||||
publication = &copied
|
||||
}
|
||||
return RouteSnapshot{
|
||||
Revision: c.revision,
|
||||
Paused: c.paused,
|
||||
FactVersion: c.factVersion,
|
||||
UpstreamByViewer: upstream,
|
||||
HostPublication: publication,
|
||||
Operation: c.operationSnapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
// RouteDiagnosticSnapshot returns the privacy-safe snapshot: connected
|
||||
// viewers sorted by join order with snapshot-local ordinals and relative
|
||||
// durations; operation is nil unless its demand peer has an ordinal.
|
||||
func (c *Controller) RouteDiagnosticSnapshot(nowMs int64) protocol.RouteDiagnosticSnapshot {
|
||||
viewers := []*participant{}
|
||||
for _, current := range c.participants.Values() {
|
||||
if current.role == protocol.RoleViewer && current.sessionID != "" && !current.departureConfirmed {
|
||||
viewers = append(viewers, current)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(viewers, func(i, j int) bool { return compareParticipant(viewers[i], viewers[j]) < 0 })
|
||||
ordinals := make(map[string]int, len(viewers))
|
||||
for index, viewer := range viewers {
|
||||
ordinals[viewer.peerID] = index + 1
|
||||
}
|
||||
children := make([]protocol.RouteDiagnosticChild, 0, len(viewers))
|
||||
for _, viewer := range viewers {
|
||||
edge, _ := c.upstreamByViewer.Get(viewer.peerID)
|
||||
record := c.routeTimings[viewer.peerID]
|
||||
finalRoute := c.currentFinalRoute(viewer.peerID)
|
||||
childCount := 0
|
||||
for _, childPeerID := range c.childrenOf(viewer.peerID) {
|
||||
if childEdge, ok := c.upstreamByViewer.Get(childPeerID); ok && childEdge.Kind == UpstreamPeer && childEdge.PhysicalActive {
|
||||
childCount++
|
||||
}
|
||||
}
|
||||
child := protocol.RouteDiagnosticChild{
|
||||
Ordinal: protocol.Int(ordinals[viewer.peerID]),
|
||||
Parent: c.diagnosticParent(edge, ordinals),
|
||||
EffectiveCapacity: protocol.Int(viewer.effectiveDownstreamCapacity),
|
||||
ChildCount: protocol.Int(childCount),
|
||||
FinalRoute: string(finalRoute),
|
||||
RejectionBucket: string(RejectionNone),
|
||||
Quality: c.diagnosticQuality(viewer.peerID, edge, nowMs),
|
||||
}
|
||||
if record != nil {
|
||||
child.DemandAgeMs = elapsedMs(record.demandAtMs, nowMs)
|
||||
if record.operationStartedAtMs != nil {
|
||||
child.QueueWaitMs = elapsedMs(record.demandAtMs, *record.operationStartedAtMs)
|
||||
}
|
||||
if record.candidateStartedAtMs != nil {
|
||||
child.CandidateStartMs = elapsedMs(record.demandAtMs, *record.candidateStartedAtMs)
|
||||
}
|
||||
if record.firstDecodedFrameAtMs != nil {
|
||||
child.FirstDecodedFrameMs = elapsedMs(record.demandAtMs, *record.firstDecodedFrameAtMs)
|
||||
}
|
||||
if record.finalAtMs != nil {
|
||||
child.FinalMs = elapsedMs(record.demandAtMs, *record.finalAtMs)
|
||||
}
|
||||
child.RejectionBucket = string(record.rejectionBucket)
|
||||
}
|
||||
if finalRoute == FinalRouteWaiting {
|
||||
child.FinalRoute = string(FinalRouteWaiting)
|
||||
if record != nil && record.finalRoute == FinalRouteFailed {
|
||||
child.FinalRoute = string(FinalRouteFailed)
|
||||
}
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
var diagnosticOperation *protocol.RouteDiagnosticOperation
|
||||
if op := c.operation; op != nil {
|
||||
if childOrdinal, ok := ordinals[op.demandPeerID]; ok {
|
||||
stage := "admission"
|
||||
if op.current != nil {
|
||||
stage = "first-frame"
|
||||
if op.current.mediaReady {
|
||||
stage = "quality-proof"
|
||||
}
|
||||
}
|
||||
diagnosticOperation = &protocol.RouteDiagnosticOperation{
|
||||
ChildOrdinal: protocol.Int(childOrdinal),
|
||||
Reason: string(op.reason),
|
||||
Stage: stage,
|
||||
Cursor: protocol.Int(op.cursor),
|
||||
CandidateCount: protocol.Int(len(op.candidates)),
|
||||
}
|
||||
}
|
||||
}
|
||||
return protocol.RouteDiagnosticSnapshot{Children: children, Operation: diagnosticOperation}
|
||||
}
|
||||
|
||||
// UpsertParticipant adds or updates a participant, aborting an operation
|
||||
// that used a replaced session; a viewer without a usable route records a
|
||||
// "join" demand when nowMs is non-nil. Panics on a Host/role inconsistency.
|
||||
func (c *Controller) UpsertParticipant(input ParticipantInput, nowMs *int64) []*Resource {
|
||||
if (input.PeerID == c.hostPeerID) != (input.Role == protocol.RoleHost) {
|
||||
panic(errors.New("Route Host identity is inconsistent"))
|
||||
}
|
||||
capacity := c.effectiveCapacity(input.EffectiveDownstreamCapacity)
|
||||
current, _ := c.participants.Get(input.PeerID)
|
||||
released := []*Resource{}
|
||||
if current != nil && current.role != input.Role {
|
||||
panic(errors.New("Route role cannot change"))
|
||||
}
|
||||
if current != nil {
|
||||
changed := false
|
||||
previousSessionID := current.sessionID
|
||||
if previousSessionID != input.SessionID {
|
||||
c.clearOpportunitiesForSessionChange(input.PeerID, input.Role)
|
||||
c.clearQualityForParticipant(input.PeerID)
|
||||
if c.operationUsesParticipantSession(input.PeerID) {
|
||||
released = append(released, c.abortOperation(nowMs, RejectionStale)...)
|
||||
}
|
||||
c.directContinuations.Delete(input.PeerID)
|
||||
c.clearSfuBootstrapForDemand(input.PeerID)
|
||||
current.sessionID = input.SessionID
|
||||
c.rebindCommittedSession(input.PeerID, input.SessionID)
|
||||
changed = true
|
||||
}
|
||||
if current.departureConfirmed {
|
||||
current.departureConfirmed = false
|
||||
changed = true
|
||||
}
|
||||
if current.effectiveDownstreamCapacity != capacity {
|
||||
current.effectiveDownstreamCapacity = capacity
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
c.touchFacts()
|
||||
c.debug("participant-updated",
|
||||
"participant", c.debugPeer(input.PeerID),
|
||||
"capacity", capacity,
|
||||
"sessionChanged", previousSessionID != input.SessionID)
|
||||
}
|
||||
if input.Role == protocol.RoleViewer && nowMs != nil && !c.usableRoute(input.PeerID) {
|
||||
c.recordDemand(input.PeerID, *nowMs, DemandJoin)
|
||||
}
|
||||
return released
|
||||
}
|
||||
// A new key appends to the insertion order (TS Map.set at 573).
|
||||
c.participants.Set(input.PeerID, &participant{
|
||||
peerID: input.PeerID,
|
||||
role: input.Role,
|
||||
sessionID: input.SessionID,
|
||||
effectiveDownstreamCapacity: capacity,
|
||||
joinOrder: c.nextJoinOrder,
|
||||
})
|
||||
c.nextJoinOrder++
|
||||
c.debug("participant-joined",
|
||||
"participant", c.debugPeer(input.PeerID),
|
||||
"role", input.Role,
|
||||
"capacity", capacity)
|
||||
if input.Role == protocol.RoleViewer && nowMs != nil {
|
||||
c.recordDemand(input.PeerID, *nowMs, DemandJoin)
|
||||
}
|
||||
c.touchFacts()
|
||||
return released
|
||||
}
|
||||
|
||||
// RebindHostIdentity moves the Host to a new peer ID (same ID delegates to
|
||||
// UpsertParticipant), tearing down the whole graph and returning every SFU
|
||||
// subscription, the publication and the aborted reservation, deduplicated.
|
||||
func (c *Controller) RebindHostIdentity(peerID, sessionID string, nowMs *int64) []*Resource {
|
||||
previousPeerID := c.hostPeerID
|
||||
if peerID == previousPeerID {
|
||||
return c.UpsertParticipant(ParticipantInput{
|
||||
PeerID: peerID,
|
||||
Role: protocol.RoleHost,
|
||||
SessionID: sessionID,
|
||||
EffectiveDownstreamCapacity: c.endpointMediaCopyCapacity,
|
||||
}, nowMs)
|
||||
}
|
||||
if c.participants.Has(peerID) {
|
||||
panic(errors.New("Route Host identity collides with another participant"))
|
||||
}
|
||||
host, _ := c.participants.Get(previousPeerID)
|
||||
if host == nil || host.role != protocol.RoleHost {
|
||||
panic(errors.New("Route Host identity is unavailable"))
|
||||
}
|
||||
|
||||
// Ordering site §4.5 #38: aborted reservation, then SFU edges in map
|
||||
// order, then the publication, deduplicated.
|
||||
released := &resourceSet{}
|
||||
if c.operation != nil {
|
||||
released.add(c.abortOperation(nowMs, RejectionStale)...)
|
||||
}
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind == UpstreamSfu && edge.PhysicalActive {
|
||||
released.add(edge.Resource)
|
||||
}
|
||||
}
|
||||
if c.hostPublication != nil && c.hostPublication.PhysicalActive {
|
||||
released.add(c.hostPublication.Resource)
|
||||
}
|
||||
c.upstreamByViewer.Clear()
|
||||
c.hostPublication = nil
|
||||
clear(c.routeTimings)
|
||||
clear(c.qualityObservations)
|
||||
clear(c.senderQualityObservations)
|
||||
clear(c.senderQualityBaselines)
|
||||
clear(c.senderQualityRegenerationBlocks)
|
||||
clear(c.qualityBaselinesPending)
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
c.directContinuations.Clear()
|
||||
c.retiringPublicationGenerations.Clear()
|
||||
c.sfuBootstrapIntent = nil
|
||||
c.consumedSfuBootstrapOpportunities.Clear()
|
||||
c.rootConvergenceRootPeerID = ""
|
||||
// Ordering site §4.1 #2: demand is re-recorded in participants order.
|
||||
for _, current := range c.participants.Values() {
|
||||
current.availabilityExhausted = false
|
||||
current.bootstrapFailureReported = false
|
||||
current.sfuFirstAtNextRoute = false
|
||||
current.consumedCandidateOpportunities.Clear()
|
||||
if current.role == protocol.RoleViewer && current.sessionID != "" && nowMs != nil {
|
||||
c.recordDemand(current.peerID, *nowMs, DemandEdgeUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// Ordering site §4.1 #1: delete + set moves the Host to the tail of the
|
||||
// insertion order while it keeps its joinOrder.
|
||||
c.participants.Delete(previousPeerID)
|
||||
host.peerID = peerID
|
||||
host.sessionID = sessionID
|
||||
host.departureConfirmed = false
|
||||
c.participants.Set(peerID, host)
|
||||
c.hostPeerID = peerID
|
||||
c.revision = c.allocateRevision()
|
||||
c.touchFacts()
|
||||
c.assertGraph()
|
||||
return released.resources()
|
||||
}
|
||||
|
||||
// DisconnectSession marks the participant's exact session as gone (grace
|
||||
// state, no revision change); false when the session does not match.
|
||||
func (c *Controller) DisconnectSession(peerID, sessionID string) bool {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if current == nil || !current.sessionIs(sessionID) {
|
||||
return false
|
||||
}
|
||||
c.clearOpportunitiesForSessionChange(peerID, current.role)
|
||||
current.sessionID = ""
|
||||
c.clearQualityForParticipant(peerID)
|
||||
c.directContinuations.Delete(peerID)
|
||||
c.clearSfuBootstrapForDemand(peerID)
|
||||
c.touchFacts()
|
||||
c.debug("participant-disconnected", "participant", c.debugPeer(peerID))
|
||||
return true
|
||||
}
|
||||
|
||||
// ConfirmDeparture finalises a viewer's departure (capacity 0, demand
|
||||
// recorded for its children when nowMs is non-nil); false for the Host or
|
||||
// an unknown peer; idempotent.
|
||||
func (c *Controller) ConfirmDeparture(peerID string, nowMs *int64) bool {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if current == nil || current.role == protocol.RoleHost {
|
||||
return false
|
||||
}
|
||||
delete(c.routeTimings, peerID)
|
||||
if current.departureConfirmed && current.sessionID == "" && current.effectiveDownstreamCapacity == 0 {
|
||||
return true
|
||||
}
|
||||
c.clearOpportunitiesForSessionChange(peerID, current.role)
|
||||
current.sessionID = ""
|
||||
c.clearQualityForParticipant(peerID)
|
||||
current.departureConfirmed = true
|
||||
current.effectiveDownstreamCapacity = 0
|
||||
c.directContinuations.Delete(peerID)
|
||||
c.clearSfuBootstrapForDemand(peerID)
|
||||
if nowMs != nil {
|
||||
for _, childPeerID := range c.childrenOf(peerID) {
|
||||
c.recordDemand(childPeerID, *nowMs, DemandParentDeparted)
|
||||
}
|
||||
}
|
||||
c.touchFacts()
|
||||
c.debug("participant-departed", "participant", c.debugPeer(peerID))
|
||||
return true
|
||||
}
|
||||
|
||||
// SetEffectiveCapacity clamps and stores the participant's downstream
|
||||
// capacity for the matching session; true even when unchanged (facts are
|
||||
// touched only on change).
|
||||
func (c *Controller) SetEffectiveCapacity(peerID, sessionID string, value int, nowMs *int64) bool {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if current == nil || !current.sessionIs(sessionID) {
|
||||
return false
|
||||
}
|
||||
capacity := c.effectiveCapacity(value)
|
||||
if current.effectiveDownstreamCapacity == capacity {
|
||||
return true
|
||||
}
|
||||
current.effectiveDownstreamCapacity = capacity
|
||||
if nowMs != nil {
|
||||
for _, childPeerID := range c.overflowPeerChildren(peerID) {
|
||||
c.recordDemand(childPeerID, *nowMs, DemandCapacityReduction)
|
||||
}
|
||||
}
|
||||
c.touchFacts()
|
||||
c.debug("capacity-updated", "participant", c.debugPeer(peerID), "capacity", capacity)
|
||||
return true
|
||||
}
|
||||
|
||||
// TouchExternalFacts reopens every SFU opportunity after an external fact
|
||||
// change (for example a share generation) and bumps the fact version.
|
||||
func (c *Controller) TouchExternalFacts() {
|
||||
c.clearSfuCandidateOpportunities()
|
||||
c.sfuBootstrapIntent = nil
|
||||
c.touchFacts()
|
||||
}
|
||||
|
||||
// hydrateEdge installs a committed edge for a connected viewer while no
|
||||
// operation is active, without changing revision or fact version. Panics
|
||||
// on an active operation, a non-viewer child or a child without a session.
|
||||
func (c *Controller) hydrateEdge(childPeerID string, edge committedEdgeSeed) {
|
||||
if c.operation != nil {
|
||||
panic(errors.New("Cannot hydrate while an operation is active"))
|
||||
}
|
||||
c.assertViewer(childPeerID)
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
childSessionID := child.sessionID
|
||||
if childSessionID == "" {
|
||||
panic(errors.New("Route child has no current session"))
|
||||
}
|
||||
committed := &CommittedEdge{
|
||||
Kind: edge.Kind,
|
||||
ChildSessionID: childSessionID,
|
||||
ParentPeerID: edge.ParentPeerID,
|
||||
PublicationGeneration: edge.PublicationGeneration,
|
||||
Transport: edge.Transport,
|
||||
ConnectionID: edge.ConnectionID,
|
||||
Usable: edge.Usable,
|
||||
PhysicalActive: edge.PhysicalActive,
|
||||
Resource: edge.Resource,
|
||||
}
|
||||
if edge.Kind == UpstreamPeer {
|
||||
// TS 750: `this.participants.get(edge.parentPeerId)?.sessionId ?? ""`.
|
||||
if parent, ok := c.participants.Get(edge.ParentPeerID); ok {
|
||||
committed.ParentSessionID = parent.sessionID
|
||||
}
|
||||
}
|
||||
// Map.set keeps the position of an existing child (§4.2 #15).
|
||||
c.upstreamByViewer.Set(childPeerID, committed)
|
||||
c.requireSenderQualityBaseline(childPeerID, false)
|
||||
c.assertGraph()
|
||||
}
|
||||
|
||||
// hydrateHostPublication installs a usable, physically active publication
|
||||
// while no operation is active; connectionID empty selects the TS default
|
||||
// "publication:<generation>". Panics on an active operation or a Host
|
||||
// without a session.
|
||||
func (c *Controller) hydrateHostPublication(generation string, resource *Resource, connectionID string) {
|
||||
if c.operation != nil {
|
||||
panic(errors.New("Cannot hydrate while an operation is active"))
|
||||
}
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
if host == nil || host.sessionID == "" {
|
||||
panic(errors.New("Route Host has no current session"))
|
||||
}
|
||||
if connectionID == "" {
|
||||
connectionID = "publication:" + generation
|
||||
}
|
||||
c.hostPublication = &HostPublication{
|
||||
Generation: generation,
|
||||
HostSessionID: host.sessionID,
|
||||
ConnectionID: connectionID,
|
||||
Usable: true,
|
||||
PhysicalActive: true,
|
||||
Resource: resource,
|
||||
}
|
||||
c.assertGraph()
|
||||
}
|
||||
|
||||
// InvalidateEdge marks the exactly identified edge unusable and records an
|
||||
// "edge-unavailable" demand; true when the guard matched (idempotent).
|
||||
func (c *Controller) InvalidateEdge(guard EdgeGuard, nowMs *int64) bool {
|
||||
child, _ := c.participants.Get(guard.ChildPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(guard.ChildPeerID)
|
||||
if child == nil || !child.sessionIs(guard.ChildSessionID) || c.revision != guard.RouteRevision ||
|
||||
edge == nil || edge.ConnectionID != guard.ConnectionID ||
|
||||
edge.ChildSessionID != guard.ChildSessionID ||
|
||||
(edge.Kind == UpstreamPeer && edge.ParentSessionID != guard.ParentSessionID) {
|
||||
return false
|
||||
}
|
||||
if edge.Usable {
|
||||
c.consumeActiveEdgeOpportunity(child, edge)
|
||||
c.clearQualityForParticipant(guard.ChildPeerID)
|
||||
edge.Usable = false
|
||||
c.directContinuations.Delete(guard.ChildPeerID)
|
||||
child.availabilityExhausted = false
|
||||
child.bootstrapFailureReported = false
|
||||
c.touchFacts()
|
||||
if nowMs != nil {
|
||||
c.recordDemand(guard.ChildPeerID, *nowMs, DemandEdgeUnavailable)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// InvalidateDirectEdgeFromParent invalidates the first committed direct edge
|
||||
// (insertion order) matching the parent-side guard.
|
||||
func (c *Controller) InvalidateDirectEdgeFromParent(input ParentEdgeGuard, nowMs *int64) bool {
|
||||
// Ordering site §4.2 #10: first match in insertion order wins.
|
||||
for childPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamPeer &&
|
||||
edge.ParentPeerID == input.ParentPeerID &&
|
||||
edge.ParentSessionID == input.ParentSessionID &&
|
||||
edge.ConnectionID == input.ConnectionID {
|
||||
return c.InvalidateEdge(EdgeGuard{
|
||||
ChildPeerID: childPeerID,
|
||||
ChildSessionID: edge.ChildSessionID,
|
||||
ParentSessionID: input.ParentSessionID,
|
||||
RouteRevision: input.RouteRevision,
|
||||
ConnectionID: input.ConnectionID,
|
||||
}, nowMs)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InvalidateHostPublication marks the identified publication unusable and
|
||||
// records demand for every SFU child; true when the guard matched (idempotent).
|
||||
func (c *Controller) InvalidateHostPublication(guard PublicationGuard, nowMs *int64) bool {
|
||||
publication := c.hostPublication
|
||||
if publication == nil || c.revision != guard.RouteRevision ||
|
||||
publication.HostSessionID != guard.HostSessionID ||
|
||||
publication.Generation != guard.Generation ||
|
||||
publication.ConnectionID != guard.ConnectionID {
|
||||
return false
|
||||
}
|
||||
if publication.Usable {
|
||||
c.clearSfuQuality()
|
||||
publication.Usable = false
|
||||
c.directContinuations.Clear()
|
||||
c.sfuBootstrapIntent = nil
|
||||
if nowMs != nil {
|
||||
for childPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamSfu {
|
||||
c.recordDemand(childPeerID, *nowMs, DemandEdgeUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.touchFacts()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AdoptDirectConnection rewrites the connection ID of a usable, active
|
||||
// direct edge the operation does not own; true when adopted (no-op when
|
||||
// unchanged).
|
||||
func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) bool {
|
||||
edge, _ := c.upstreamByViewer.Get(input.ChildPeerID)
|
||||
if (c.operation != nil && c.operation.childPeerID == input.ChildPeerID) ||
|
||||
c.revision != input.RouteRevision ||
|
||||
edge == nil ||
|
||||
edge.Kind != UpstreamPeer ||
|
||||
edge.Transport != TransportDirect ||
|
||||
!edge.Usable ||
|
||||
!edge.PhysicalActive ||
|
||||
edge.ChildSessionID != input.ChildSessionID ||
|
||||
edge.ParentSessionID != input.ParentSessionID ||
|
||||
edge.ConnectionID != input.ConnectionID ||
|
||||
input.NewConnectionID == "" {
|
||||
return false
|
||||
}
|
||||
if input.NewConnectionID == edge.ConnectionID {
|
||||
return true
|
||||
}
|
||||
c.clearQualityForParticipant(input.ChildPeerID)
|
||||
edge.ConnectionID = input.NewConnectionID
|
||||
if child, ok := c.participants.Get(input.ChildPeerID); ok {
|
||||
child.availabilityExhausted = false
|
||||
}
|
||||
c.touchFacts()
|
||||
return true
|
||||
}
|
||||
|
||||
// RetireHostPublication retires the physically active publication and its
|
||||
// subscriptions when no attempt is live and the guard matches, returning the
|
||||
// subscriptions then the publication resource; empty otherwise.
|
||||
func (c *Controller) RetireHostPublication(guard PublicationGuard) []*Resource {
|
||||
publication := c.hostPublication
|
||||
if (c.operation != nil && c.operation.current != nil) || publication == nil || !publication.PhysicalActive ||
|
||||
c.revision != guard.RouteRevision ||
|
||||
publication.HostSessionID != guard.HostSessionID ||
|
||||
publication.Generation != guard.Generation ||
|
||||
publication.ConnectionID != guard.ConnectionID {
|
||||
return []*Resource{}
|
||||
}
|
||||
// Ordering site §4.2 #11: subscriptions in map order, then the publication.
|
||||
released := []*Resource{}
|
||||
for viewerPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamSfu && edge.PublicationGeneration == publication.Generation {
|
||||
c.retireSfuEdge(viewerPeerID, edge, &released, c.retainsAnchor(viewerPeerID))
|
||||
}
|
||||
}
|
||||
publication.PhysicalActive = false
|
||||
publication.Usable = false
|
||||
c.hostPublication = nil
|
||||
c.clearSfuQuality()
|
||||
c.revision = c.allocateRevision()
|
||||
if c.operation != nil {
|
||||
c.operation.baseRevision = c.revision
|
||||
}
|
||||
c.touchFacts()
|
||||
released = append(released, publication.Resource)
|
||||
c.pruneRetiringSfuAnchors()
|
||||
return released
|
||||
}
|
||||
|
||||
// SetPaused pauses (aborting the operation and clearing quality state) or
|
||||
// resumes; returns the aborted reservation's resources.
|
||||
func (c *Controller) SetPaused(paused bool, nowMs *int64) []*Resource {
|
||||
if c.paused == paused {
|
||||
return []*Resource{}
|
||||
}
|
||||
c.paused = paused
|
||||
if paused {
|
||||
clear(c.qualityObservations)
|
||||
clear(c.qualityBaselinesPending)
|
||||
clear(c.senderQualityObservations)
|
||||
clear(c.senderQualityBaselines)
|
||||
clear(c.senderQualityRegenerationBlocks)
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
}
|
||||
c.touchFacts()
|
||||
if paused {
|
||||
return c.abortOperation(nowMs, RejectionAborted)
|
||||
}
|
||||
return []*Resource{}
|
||||
}
|
||||
|
||||
// Dispose clears all state, pauses the controller and returns the live
|
||||
// reservation's resources then every committed resource, deduplicated.
|
||||
func (c *Controller) Dispose() []*Resource {
|
||||
// Ordering site §4.5 #39: reservation first, then committed resources.
|
||||
resources := &resourceSet{}
|
||||
if c.operation != nil && c.operation.current != nil {
|
||||
resources.add(reservationResources(c.operation.current.reservation)...)
|
||||
}
|
||||
resources.add(c.committedResources()...)
|
||||
c.operation = nil
|
||||
c.upstreamByViewer.Clear()
|
||||
clear(c.qualityObservations)
|
||||
clear(c.qualityBaselinesPending)
|
||||
clear(c.senderQualityObservations)
|
||||
clear(c.senderQualityBaselines)
|
||||
clear(c.senderQualityRegenerationBlocks)
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
c.hostPublication = nil
|
||||
c.participants.Clear()
|
||||
clear(c.routeTimings)
|
||||
c.directContinuations.Clear()
|
||||
c.retiringPublicationGenerations.Clear()
|
||||
c.sfuBootstrapIntent = nil
|
||||
c.consumedSfuBootstrapOpportunities.Clear()
|
||||
c.rootConvergenceRootPeerID = ""
|
||||
// nextJoinOrder, revision, latestRevision and factVersion survive (TS 2314-2339).
|
||||
c.paused = true
|
||||
return resources.resources()
|
||||
}
|
||||
|
||||
// abortOperation ports the private abortOperation (4050): finishes the
|
||||
// demand timing when nowMs is given, releases the live reservation and
|
||||
// clears the operation.
|
||||
func (c *Controller) abortOperation(nowMs *int64, bucket RejectionBucket) []*Resource {
|
||||
if c.operation == nil {
|
||||
return []*Resource{}
|
||||
}
|
||||
if nowMs != nil {
|
||||
c.finishTiming(c.operation.demandPeerID, *nowMs, c.currentFinalRoute(c.operation.demandPeerID), bucket, false)
|
||||
}
|
||||
op := c.operation
|
||||
released := []*Resource{}
|
||||
if op.current != nil {
|
||||
released = reservationResources(op.current.reservation)
|
||||
c.advanceActiveRevision(op)
|
||||
}
|
||||
c.operation = nil
|
||||
return released
|
||||
}
|
||||
|
||||
// operationUsesParticipantSession ports 4072.
|
||||
func (c *Controller) operationUsesParticipantSession(peerID string) bool {
|
||||
op := c.operation
|
||||
if op == nil {
|
||||
return false
|
||||
}
|
||||
if op.childPeerID == peerID || op.demandPeerID == peerID {
|
||||
return true
|
||||
}
|
||||
tuple := op.currentTuple()
|
||||
if tuple != nil && tuple.Kind == UpstreamPeer {
|
||||
return tuple.ParentPeerID == peerID
|
||||
}
|
||||
return peerID == c.hostPeerID
|
||||
}
|
||||
|
||||
// effectiveCapacity ports 4925: panics "Effective capacity is invalid" for
|
||||
// a negative or unsafe value and clamps to the endpoint capacity.
|
||||
func (c *Controller) effectiveCapacity(value int) int {
|
||||
if !isSafeInteger(int64(value)) || value < 0 {
|
||||
panic(errors.New("Effective capacity is invalid"))
|
||||
}
|
||||
return min(value, c.endpointMediaCopyCapacity)
|
||||
}
|
||||
|
||||
func (c *Controller) touchFacts() { c.factVersion++ }
|
||||
|
||||
// allocateRevision ports 4932; the revision space is MAX_SAFE_INTEGER.
|
||||
func (c *Controller) allocateRevision() int64 {
|
||||
if c.latestRevision >= protocol.MaxMediaRouteRevision {
|
||||
panic(errors.New("Media route revision space exhausted"))
|
||||
}
|
||||
c.latestRevision++
|
||||
return c.latestRevision
|
||||
}
|
||||
|
||||
// isSafeInteger is Number.isSafeInteger for a value that is already integral.
|
||||
func isSafeInteger(value int64) bool {
|
||||
return value >= -maxSafeInteger && value <= maxSafeInteger
|
||||
}
|
||||
|
||||
// compareParticipant ports 4939. joinOrder is allocated once per
|
||||
// participant (573-577) and preserved across rebindHostIdentity, so two live
|
||||
// participants never share one and the TS localeCompare (ICU) tiebreak is
|
||||
// unreachable; strings.Compare stands in for it.
|
||||
func compareParticipant(left, right *participant) int {
|
||||
if left.joinOrder != right.joinOrder {
|
||||
if left.joinOrder < right.joinOrder {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(left.peerID, right.peerID)
|
||||
}
|
||||
|
||||
// stablePairRank ports 4955: the first 6 bytes, big-endian, of
|
||||
// sha256(child + "\0" + parent). Byte-exact: it breaks parent ties.
|
||||
func stablePairRank(childPeerID, parentPeerID string) int64 {
|
||||
sum := sha256.Sum256([]byte(childPeerID + "\x00" + parentPeerID))
|
||||
var rank int64
|
||||
for _, b := range sum[:6] {
|
||||
rank = rank<<8 | int64(b)
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// safeAdd ports 4969. JS Math.round is half-up while math.Round is
|
||||
// half-away-from-zero; they differ only for negative halves, which the
|
||||
// `rounded < 0` branch drops either way. NaN and +/-Inf drop the window.
|
||||
func safeAdd(total int64, delta float64) int64 {
|
||||
rounded := math.Round(delta)
|
||||
if math.IsNaN(rounded) || math.IsInf(rounded, 0) || rounded < 0 || rounded > float64(maxSafeInteger) {
|
||||
return total
|
||||
}
|
||||
value := int64(rounded)
|
||||
if value > maxSafeInteger-total {
|
||||
return maxSafeInteger
|
||||
}
|
||||
return total + value
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// debugEnabled is the port of `debuglog("screener-route")`: read once,
|
||||
// enabled by SCREENER_DEBUG=route (comma-separated like NODE_DEBUG).
|
||||
var debugEnabled = routeDebugEnabled(os.Getenv("SCREENER_DEBUG"))
|
||||
|
||||
func routeDebugEnabled(value string) bool {
|
||||
for _, section := range strings.Split(value, ",") {
|
||||
if strings.TrimSpace(section) == "route" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// debugCandidate is the `{route, transition}` item of "operation-started".
|
||||
type debugCandidate struct {
|
||||
Route string
|
||||
Transition TransitionKind
|
||||
}
|
||||
|
||||
// debug ports 4911: one sanitised event with the room's debug ID, the
|
||||
// current revision and fact version, then the caller's key/value details.
|
||||
// Never pass raw peer IDs, session IDs or connection IDs; use debugPeer and
|
||||
// debugTuple. It is a no-op without the env flag or a DebugRoomID; the
|
||||
// level is Info because the env flag is the gate, as NODE_DEBUG was.
|
||||
func (c *Controller) debug(event string, details ...any) {
|
||||
if !debugEnabled || c.debugRoomID == "" {
|
||||
return
|
||||
}
|
||||
args := make([]any, 0, 8+len(details))
|
||||
args = append(args,
|
||||
"event", event,
|
||||
"roomId", c.debugRoomID,
|
||||
"revision", c.revision,
|
||||
"factVersion", c.factVersion)
|
||||
args = append(args, details...)
|
||||
slog.Info("screener-route", args...)
|
||||
}
|
||||
|
||||
// debugPeer ports 4895: "host", "viewer-<joinOrder>" or "viewer-unknown".
|
||||
func (c *Controller) debugPeer(peerID string) string {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if peerID == c.hostPeerID || (current != nil && current.role == protocol.RoleHost) {
|
||||
return "host"
|
||||
}
|
||||
if current == nil {
|
||||
return "viewer-unknown"
|
||||
}
|
||||
return "viewer-" + strconv.FormatInt(current.joinOrder, 10)
|
||||
}
|
||||
|
||||
// debugTuple ports 4903: "p2p:<label>[:regenerate]" or "sfu:<publication>".
|
||||
func (c *Controller) debugTuple(tuple CandidateTuple) string {
|
||||
if tuple.Kind == UpstreamPeer {
|
||||
label := "p2p:" + c.debugPeer(tuple.ParentPeerID)
|
||||
if tuple.Regenerate {
|
||||
label += ":regenerate"
|
||||
}
|
||||
return label
|
||||
}
|
||||
return "sfu:" + string(tuple.Publication)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package route ports src/server/room-route-controller.ts: the pure,
|
||||
// synchronous decision logic that owns one room's media route graph, its
|
||||
// single in-flight route operation, the quality/opportunity ledgers and the
|
||||
// privacy-safe diagnostic snapshot.
|
||||
//
|
||||
// The controller performs no I/O, starts no timers and never locks. Every
|
||||
// method is a state transition over the nowMs the caller passes in; the
|
||||
// caller (signal.Server) holds the global mutex around every call and
|
||||
// releases exactly the *Resource values a method returns, in order.
|
||||
//
|
||||
// Resources are compared by pointer identity, mirroring the JS Set semantics
|
||||
// of the TypeScript source. Programming-error throws in the source
|
||||
// (assertGraph, bad reservations, bad options) become panics with the same
|
||||
// messages.
|
||||
package route
|
||||
@@ -0,0 +1,287 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// pruneDepartedLeaves ports 4015: removes departed viewers without
|
||||
// children to a fixpoint, in participants order (§4.1 #4), and drops an
|
||||
// orphaned publication.
|
||||
func (c *Controller) pruneDepartedLeaves(released *[]*Resource) []string {
|
||||
removed := []string{}
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
// Live iteration: deleting the visited entry is JS-safe (ordered.Map.All).
|
||||
for _, current := range c.participants.All() {
|
||||
if current.role != protocol.RoleViewer || !current.departureConfirmed || len(c.childrenOf(current.peerID)) > 0 {
|
||||
continue
|
||||
}
|
||||
edge, _ := c.upstreamByViewer.Get(current.peerID)
|
||||
if edge != nil && edge.Kind == UpstreamSfu {
|
||||
c.retireSfuEdge(current.peerID, edge, released, false)
|
||||
} else {
|
||||
c.upstreamByViewer.Delete(current.peerID)
|
||||
}
|
||||
c.clearQualityForParticipant(current.peerID)
|
||||
c.participants.Delete(current.peerID)
|
||||
delete(c.routeTimings, current.peerID)
|
||||
c.directContinuations.Delete(current.peerID)
|
||||
c.clearSfuBootstrapForDemand(current.peerID)
|
||||
removed = append(removed, current.peerID)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
if !c.hasSfuSubscribers() && c.hostPublication != nil {
|
||||
c.clearSfuQuality()
|
||||
if c.hostPublication.PhysicalActive {
|
||||
*released = append(*released, c.hostPublication.Resource)
|
||||
}
|
||||
c.hostPublication = nil
|
||||
}
|
||||
c.revision = c.allocateRevision()
|
||||
c.touchFacts()
|
||||
c.pruneRetiringSfuAnchors()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// retireInvalidOperationEdge ports 4133.
|
||||
func (c *Controller) retireInvalidOperationEdge(childPeerID string, released *[]*Resource) bool {
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
if edge == nil || !c.edgeRequiresMove(childPeerID, edge) {
|
||||
return false
|
||||
}
|
||||
if edge.Kind == UpstreamSfu {
|
||||
c.retireSfuEdge(childPeerID, edge, released, c.retainsAnchor(childPeerID))
|
||||
c.pruneRetiringSfuAnchors()
|
||||
} else {
|
||||
edge.PhysicalActive = false
|
||||
edge.Usable = false
|
||||
}
|
||||
if edge.Kind == UpstreamSfu && !c.hasSfuSubscribers() && c.hostPublication != nil {
|
||||
publication := c.hostPublication
|
||||
if publication.PhysicalActive {
|
||||
*released = append(*released, publication.Resource)
|
||||
}
|
||||
publication.PhysicalActive = false
|
||||
publication.Usable = false
|
||||
c.hostPublication = nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// edgeRequiresMove ports 4153.
|
||||
func (c *Controller) edgeRequiresMove(childPeerID string, edge *CommittedEdge) bool {
|
||||
if !edge.Usable || !edge.PhysicalActive {
|
||||
return true
|
||||
}
|
||||
if edge.Kind == UpstreamSfu {
|
||||
return c.hostPublication == nil || !c.hostPublication.Usable ||
|
||||
!c.hostPublication.PhysicalActive ||
|
||||
c.hostPublication.Generation != edge.PublicationGeneration
|
||||
}
|
||||
if parent, ok := c.participants.Get(edge.ParentPeerID); ok && parent.departureConfirmed {
|
||||
return true
|
||||
}
|
||||
for _, overflowing := range c.overflowPeerChildren(edge.ParentPeerID) {
|
||||
if overflowing == childPeerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rebindCommittedSession ports 4747: the reconnecting peer keeps its edges
|
||||
// and publication, which take its new session id. The TS `{released,
|
||||
// retired}` result is dropped because it was dead: nothing ever joined the
|
||||
// released set and retired was the constant false, so the caller's
|
||||
// `rebound.retired &&` branch was unreachable (map R12).
|
||||
func (c *Controller) rebindCommittedSession(peerID, sessionID string) {
|
||||
if ownEdge, ok := c.upstreamByViewer.Get(peerID); ok {
|
||||
ownEdge.ChildSessionID = sessionID
|
||||
}
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind != UpstreamPeer || edge.ParentPeerID != peerID {
|
||||
continue
|
||||
}
|
||||
edge.ParentSessionID = sessionID
|
||||
}
|
||||
if peerID == c.hostPeerID && c.hostPublication != nil {
|
||||
c.hostPublication.HostSessionID = sessionID
|
||||
}
|
||||
}
|
||||
|
||||
// removePublicationGeneration ports 4766 (§4.2 #12).
|
||||
func (c *Controller) removePublicationGeneration(generation string) []*Resource {
|
||||
released := []*Resource{}
|
||||
for viewerPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind != UpstreamSfu || edge.PublicationGeneration != generation {
|
||||
continue
|
||||
}
|
||||
c.retireSfuEdge(viewerPeerID, edge, &released, c.retainsAnchor(viewerPeerID))
|
||||
}
|
||||
if c.hostPublication != nil && c.hostPublication.Generation == generation {
|
||||
if c.hostPublication.PhysicalActive {
|
||||
released = append(released, c.hostPublication.Resource)
|
||||
}
|
||||
c.hostPublication = nil
|
||||
}
|
||||
c.pruneRetiringSfuAnchors()
|
||||
return released
|
||||
}
|
||||
|
||||
// retainsAnchor is the TS default argument of retireSfuEdge
|
||||
// (`this.childrenOf(viewerPeerId).length > 0`), evaluated at call time.
|
||||
func (c *Controller) retainsAnchor(viewerPeerID string) bool {
|
||||
return len(c.childrenOf(viewerPeerID)) > 0
|
||||
}
|
||||
|
||||
// retireSfuEdge ports 4780: releases the subscription only while it is
|
||||
// still physically active, then keeps the edge as an anchor for attached
|
||||
// peer descendants or deletes it.
|
||||
func (c *Controller) retireSfuEdge(viewerPeerID string, edge *CommittedEdge, released *[]*Resource, retainAnchor bool) {
|
||||
if edge.PhysicalActive {
|
||||
*released = append(*released, edge.Resource)
|
||||
}
|
||||
c.clearQualityForParticipant(viewerPeerID)
|
||||
if retainAnchor {
|
||||
edge.Usable = false
|
||||
edge.PhysicalActive = false
|
||||
c.retiringPublicationGenerations.Set(edge.PublicationGeneration, struct{}{})
|
||||
} else {
|
||||
c.upstreamByViewer.Delete(viewerPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// hasRetiringAnchor reports whether an inactive SFU edge still references
|
||||
// the generation (the `some` at 4816 and 4880).
|
||||
func (c *Controller) hasRetiringAnchor(generation string) bool {
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind == UpstreamSfu && !edge.PhysicalActive && edge.PublicationGeneration == generation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pruneRetiringSfuAnchors ports 4797 (§4.2 #18, §4.5 #46).
|
||||
func (c *Controller) pruneRetiringSfuAnchors() {
|
||||
removed := true
|
||||
for removed {
|
||||
removed = false
|
||||
for viewerPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamSfu &&
|
||||
!edge.PhysicalActive &&
|
||||
c.retiringPublicationGenerations.Has(edge.PublicationGeneration) &&
|
||||
len(c.childrenOf(viewerPeerID)) == 0 {
|
||||
c.upstreamByViewer.Delete(viewerPeerID)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for generation := range c.retiringPublicationGenerations.All() {
|
||||
if !c.hasRetiringAnchor(generation) {
|
||||
c.retiringPublicationGenerations.Delete(generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertGraph ports 4827: programming-error panics with the TS messages
|
||||
// (§4.2 #20, §4.1 #6 decide which violation is reported first).
|
||||
func (c *Controller) assertGraph() {
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
if host == nil || host.role != protocol.RoleHost {
|
||||
panic(errors.New("Route Host is missing"))
|
||||
}
|
||||
for child, edge := range c.upstreamByViewer.All() {
|
||||
c.assertViewer(child)
|
||||
if edge.Kind == UpstreamPeer && !c.participants.Has(edge.ParentPeerID) {
|
||||
panic(errors.New("Route parent is missing"))
|
||||
}
|
||||
if edge.Kind == UpstreamSfu && edge.PhysicalActive &&
|
||||
(c.hostPublication == nil || c.hostPublication.Generation != edge.PublicationGeneration) {
|
||||
panic(errors.New("SFU publication is stale"))
|
||||
}
|
||||
if edge.Kind == UpstreamSfu && !edge.PhysicalActive &&
|
||||
!c.retiringPublicationGenerations.Has(edge.PublicationGeneration) {
|
||||
panic(errors.New("Retiring SFU publication is unknown"))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
current := child
|
||||
for current != c.hostPeerID {
|
||||
if _, visited := seen[current]; visited {
|
||||
panic(errors.New("Peer route contains a cycle"))
|
||||
}
|
||||
seen[current] = struct{}{}
|
||||
currentEdge, ok := c.upstreamByViewer.Get(current)
|
||||
if !ok {
|
||||
panic(errors.New("Route is not source-reachable"))
|
||||
}
|
||||
if currentEdge.Kind == UpstreamSfu {
|
||||
var unknown bool
|
||||
if currentEdge.PhysicalActive {
|
||||
unknown = c.hostPublication == nil || c.hostPublication.Generation != currentEdge.PublicationGeneration
|
||||
} else {
|
||||
unknown = !c.retiringPublicationGenerations.Has(currentEdge.PublicationGeneration)
|
||||
}
|
||||
if unknown {
|
||||
panic(errors.New("Route terminates at an unknown SFU publication"))
|
||||
}
|
||||
break
|
||||
}
|
||||
current = currentEdge.ParentPeerID
|
||||
}
|
||||
}
|
||||
for _, current := range c.participants.Values() {
|
||||
if !protocol.EndpointMediaCopyCountFits(c.usedSlots(current.peerID), c.endpointMediaCopyCapacity, protocol.EndpointMediaCopySteady) {
|
||||
panic(errors.New("Committed route exceeds endpoint capacity"))
|
||||
}
|
||||
}
|
||||
for generation := range c.retiringPublicationGenerations.All() {
|
||||
if !c.hasRetiringAnchor(generation) {
|
||||
panic(errors.New("Retiring SFU publication has no anchor"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertViewer ports 4891.
|
||||
func (c *Controller) assertViewer(peerID string) {
|
||||
if current, ok := c.participants.Get(peerID); !ok || current.role != protocol.RoleViewer {
|
||||
panic(errors.New("Route child must be a Viewer"))
|
||||
}
|
||||
}
|
||||
|
||||
// hasSfuSubscribers ports 4431.
|
||||
func (c *Controller) hasSfuSubscribers() bool {
|
||||
return c.sfuSubscriberCount() > 0
|
||||
}
|
||||
|
||||
// sfuSubscriberCount ports 4435.
|
||||
func (c *Controller) sfuSubscriberCount() int {
|
||||
count := 0
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind == UpstreamSfu && edge.PhysicalActive {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// committedResources ports 4439: SFU subscriptions in map order, then the
|
||||
// publication (§4.2 #13).
|
||||
func (c *Controller) committedResources() []*Resource {
|
||||
resources := []*Resource{}
|
||||
for _, edge := range c.upstreamByViewer.Values() {
|
||||
if edge.Kind == UpstreamSfu && edge.PhysicalActive {
|
||||
resources = append(resources, edge.Resource)
|
||||
}
|
||||
}
|
||||
if c.hostPublication != nil && c.hostPublication.PhysicalActive {
|
||||
resources = append(resources, c.hostPublication.Resource)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package route
|
||||
|
||||
// Test harness ported one-to-one from tests/room-route-controller.test.ts
|
||||
// (lines 14-356 and preparePeer at 5017). TS resources were plain strings;
|
||||
// here every resource is a distinct *Resource carrying the TS string as
|
||||
// Label, and released slices are compared by label in order.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
HOST = "host_12345678"
|
||||
A = "viewer_a_12345678"
|
||||
B = "viewer_b_12345678"
|
||||
C = "viewer_c_12345678"
|
||||
D = "viewer_d_12345678"
|
||||
E = "viewer_e_12345678"
|
||||
F = "viewer_f_12345678"
|
||||
)
|
||||
|
||||
// ms is the optional nowMs argument (TS: an omitted parameter is nil here).
|
||||
func ms(v int64) *int64 { return &v }
|
||||
|
||||
func optInt(v int64) *protocol.Int {
|
||||
value := protocol.Int(v)
|
||||
return &value
|
||||
}
|
||||
|
||||
func optNum(v float64) *protocol.Num {
|
||||
value := protocol.Num(v)
|
||||
return &value
|
||||
}
|
||||
|
||||
// resourceLabels names the resources these tests create. The label lives here,
|
||||
// not on Resource, because the controller compares resources by pointer
|
||||
// identity only (TS: the tests instantiated the type parameter with a string).
|
||||
// No test in this package calls t.Parallel(), so the map needs no lock.
|
||||
var resourceLabels = map[*Resource]string{}
|
||||
|
||||
// res makes a fresh labelled resource (TS: a plain string).
|
||||
func res(label string) *Resource {
|
||||
resource := &Resource{}
|
||||
resourceLabels[resource] = label
|
||||
return resource
|
||||
}
|
||||
|
||||
func labels(resources []*Resource) []string {
|
||||
out := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
out = append(out, resourceLabels[resource])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sessionOf(peerID string) string { return peerID + "_session" }
|
||||
|
||||
func parentSession(parentPeerID string) string {
|
||||
if parentPeerID == HOST {
|
||||
return "host_session"
|
||||
}
|
||||
return sessionOf(parentPeerID)
|
||||
}
|
||||
|
||||
// newController ports controller(capacity, options): OperationTimeoutMs
|
||||
// defaults to 10_000 and the Host is upserted at the same capacity.
|
||||
func newController(capacity int, options Options) *Controller {
|
||||
options.HostPeerID = HOST
|
||||
options.EndpointMediaCopyCapacity = capacity
|
||||
if options.OperationTimeoutMs == 0 {
|
||||
options.OperationTimeoutMs = 10_000
|
||||
}
|
||||
routes := New(options)
|
||||
routes.UpsertParticipant(ParticipantInput{
|
||||
PeerID: HOST,
|
||||
Role: protocol.RoleHost,
|
||||
SessionID: "host_session",
|
||||
EffectiveDownstreamCapacity: capacity,
|
||||
}, nil)
|
||||
return routes
|
||||
}
|
||||
|
||||
func addViewer(routes *Controller, peerID string, capacity int, nowMs *int64) {
|
||||
routes.UpsertParticipant(ParticipantInput{
|
||||
PeerID: peerID,
|
||||
Role: protocol.RoleViewer,
|
||||
SessionID: sessionOf(peerID),
|
||||
EffectiveDownstreamCapacity: capacity,
|
||||
}, nowMs)
|
||||
}
|
||||
|
||||
func viewerInput(peerID, sessionID string, capacity int) ParticipantInput {
|
||||
return ParticipantInput{
|
||||
PeerID: peerID,
|
||||
Role: protocol.RoleViewer,
|
||||
SessionID: sessionID,
|
||||
EffectiveDownstreamCapacity: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
func hostInput(sessionID string, capacity int) ParticipantInput {
|
||||
return ParticipantInput{
|
||||
PeerID: HOST,
|
||||
Role: protocol.RoleHost,
|
||||
SessionID: sessionID,
|
||||
EffectiveDownstreamCapacity: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
func cursorGuard(operation *OperationSnapshot) CandidateCursorGuard {
|
||||
return CandidateCursorGuard{
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ChildSessionID: operation.ChildSessionID,
|
||||
BaseRevision: operation.BaseRevision,
|
||||
FactVersion: operation.FactVersion,
|
||||
Cursor: operation.Cursor,
|
||||
Plan: operation.Candidates[operation.Cursor],
|
||||
}
|
||||
}
|
||||
|
||||
// must ports the TS non-null assertion `operation!`.
|
||||
func must(t *testing.T, operation *OperationSnapshot) *OperationSnapshot {
|
||||
t.Helper()
|
||||
if operation == nil {
|
||||
t.Fatal("expected an operation")
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
// noOperation ports `expect(operation).toBeUndefined()`.
|
||||
func noOperation(t *testing.T, operation *OperationSnapshot) {
|
||||
t.Helper()
|
||||
if operation != nil {
|
||||
t.Fatalf("expected no operation, got child %q reason %q", operation.ChildPeerID, operation.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// beginCandidate ports beginCandidate(routes, input): the guard is derived
|
||||
// from the current operation and non-direct reservations default the Host
|
||||
// session to "host_session".
|
||||
func beginCandidate(t *testing.T, routes *Controller, input BeginInput) BeginResult {
|
||||
t.Helper()
|
||||
input.Guard = cursorGuard(must(t, routes.Snapshot().Operation))
|
||||
if input.Reservation.Kind != ReservationDirect && input.HostSessionID == "" {
|
||||
input.HostSessionID = "host_session"
|
||||
}
|
||||
return routes.BeginCurrentCandidate(input)
|
||||
}
|
||||
|
||||
// beginOperation ports `beginCandidate(...).operation!`.
|
||||
func beginOperation(t *testing.T, routes *Controller, input BeginInput) *OperationSnapshot {
|
||||
t.Helper()
|
||||
return must(t, beginCandidate(t, routes, input).Operation)
|
||||
}
|
||||
|
||||
func skipCandidate(t *testing.T, routes *Controller, nowMs int64) BeginResult {
|
||||
t.Helper()
|
||||
return routes.SkipCurrentCandidate(cursorGuard(must(t, routes.Snapshot().Operation)), nowMs, RejectionStale)
|
||||
}
|
||||
|
||||
func commitCurrent(t *testing.T, routes *Controller, nowMs int64, connectionID string) CandidateGuard {
|
||||
t.Helper()
|
||||
prepared := beginOperation(t, routes, BeginInput{NowMs: nowMs, ConnectionID: connectionID, Reservation: direct()})
|
||||
guard := CandidateGuard{
|
||||
ChildPeerID: prepared.ChildPeerID,
|
||||
ChildSessionID: prepared.ChildSessionID,
|
||||
Revision: prepared.Current.Revision,
|
||||
ConnectionID: connectionID,
|
||||
}
|
||||
eq(t, routes.CandidateReady(guard, nowMs+1, nil, CandidateProof{}).Accepted, true)
|
||||
return guard
|
||||
}
|
||||
|
||||
// guardFor is the literal `{childPeerId, childSessionId: `${id}_session`, revision, connectionId}` the tests build.
|
||||
func guardFor(peerID string, revision int64, connectionID string) CandidateGuard {
|
||||
return CandidateGuard{ChildPeerID: peerID, ChildSessionID: sessionOf(peerID), Revision: revision, ConnectionID: connectionID}
|
||||
}
|
||||
|
||||
func edgeGuard(childPeerID, parentSessionID string, revision int64, connectionID string) EdgeGuard {
|
||||
return EdgeGuard{
|
||||
ChildPeerID: childPeerID,
|
||||
ChildSessionID: sessionOf(childPeerID),
|
||||
ParentSessionID: parentSessionID,
|
||||
RouteRevision: revision,
|
||||
ConnectionID: connectionID,
|
||||
}
|
||||
}
|
||||
|
||||
func direct() CandidateReservation { return CandidateReservation{Kind: ReservationDirect} }
|
||||
|
||||
func directOverlap(overlap string) CandidateReservation {
|
||||
return CandidateReservation{Kind: ReservationDirect, Overlap: res(overlap)}
|
||||
}
|
||||
|
||||
func sfuReuse(edge string) CandidateReservation {
|
||||
return CandidateReservation{Kind: ReservationSfuReuse, Edge: res(edge)}
|
||||
}
|
||||
|
||||
func sfuCreate(edge, publication string) CandidateReservation {
|
||||
return CandidateReservation{Kind: ReservationSfuCreate, Edge: res(edge), Publication: res(publication)}
|
||||
}
|
||||
|
||||
func sfuCreateOverlap(edge, publication, overlap string) CandidateReservation {
|
||||
reservation := sfuCreate(edge, publication)
|
||||
reservation.Overlap = res(overlap)
|
||||
return reservation
|
||||
}
|
||||
|
||||
func peerEdge(parentPeerID, connectionID string) committedEdgeSeed {
|
||||
return committedEdgeSeed{
|
||||
Kind: UpstreamPeer,
|
||||
ParentPeerID: parentPeerID,
|
||||
Transport: TransportDirect,
|
||||
ConnectionID: connectionID,
|
||||
Usable: true,
|
||||
PhysicalActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
func sfuEdge(generation, connectionID, resource string) committedEdgeSeed {
|
||||
return committedEdgeSeed{
|
||||
Kind: UpstreamSfu,
|
||||
PublicationGeneration: generation,
|
||||
Transport: TransportSfu,
|
||||
ConnectionID: connectionID,
|
||||
Usable: true,
|
||||
PhysicalActive: true,
|
||||
Resource: res(resource),
|
||||
}
|
||||
}
|
||||
|
||||
func peerTuple(parentPeerID string) CandidateTuple {
|
||||
return CandidateTuple{Kind: UpstreamPeer, ParentPeerID: parentPeerID, Transport: TransportDirect}
|
||||
}
|
||||
|
||||
func regenerateTuple(parentPeerID string) CandidateTuple {
|
||||
tuple := peerTuple(parentPeerID)
|
||||
tuple.Regenerate = true
|
||||
return tuple
|
||||
}
|
||||
|
||||
func sfuTuple(publication PublicationPlan) CandidateTuple {
|
||||
return CandidateTuple{Kind: UpstreamSfu, Publication: publication}
|
||||
}
|
||||
|
||||
func tuples(operation *OperationSnapshot) []CandidateTuple {
|
||||
return tuplesFrom(operation, 0)
|
||||
}
|
||||
|
||||
func tuplesFrom(operation *OperationSnapshot, from int) []CandidateTuple {
|
||||
out := make([]CandidateTuple, 0, len(operation.Candidates))
|
||||
for _, candidate := range operation.Candidates[from:] {
|
||||
out = append(out, candidate.Tuple)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tupleKinds ports `operation.candidates.map((candidate) => candidate.tuple.kind)`.
|
||||
func tupleKinds(operation *OperationSnapshot) []UpstreamKind {
|
||||
out := make([]UpstreamKind, 0, len(operation.Candidates))
|
||||
for _, candidate := range operation.Candidates {
|
||||
out = append(out, candidate.Tuple.Kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasTuple(operation *OperationSnapshot, match func(CandidateTuple) bool) bool {
|
||||
return slices.ContainsFunc(tuples(operation), match)
|
||||
}
|
||||
|
||||
func isSfu(tuple CandidateTuple) bool { return tuple.Kind == UpstreamSfu }
|
||||
func isPeer(tuple CandidateTuple) bool { return tuple.Kind == UpstreamPeer }
|
||||
|
||||
// edgeOf ports `routes.snapshot().upstreamByViewer.get(peerId)` where the
|
||||
// test dereferences the edge.
|
||||
func edgeOf(t *testing.T, routes *Controller, peerID string) CommittedEdge {
|
||||
t.Helper()
|
||||
edge, ok := routes.Snapshot().UpstreamByViewer.Get(peerID)
|
||||
if !ok {
|
||||
t.Fatalf("expected an upstream edge for %q", peerID)
|
||||
}
|
||||
return edge
|
||||
}
|
||||
|
||||
func hasEdge(routes *Controller, peerID string) bool {
|
||||
return routes.Snapshot().UpstreamByViewer.Has(peerID)
|
||||
}
|
||||
|
||||
func retireLastSfuPublicationAfterReuseDeadline(t *testing.T) (*Controller, int64) {
|
||||
t.Helper()
|
||||
routes := newController(2, Options{SfuEnabled: true})
|
||||
for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} {
|
||||
addViewer(routes, seed[0], 0, nil)
|
||||
routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1]))
|
||||
}
|
||||
addViewer(routes, A, 0, ms(0))
|
||||
|
||||
bootstrap := must(t, routes.Reconcile(0).Operation)
|
||||
eq(t, bootstrap.DemandPeerID, A)
|
||||
eq(t, bootstrap.Reason, DemandSfuBootstrap)
|
||||
eqSlice(t, tuples(bootstrap), []CandidateTuple{sfuTuple(PublicationCreate)})
|
||||
carrierPeerID := bootstrap.ChildPeerID
|
||||
carrier := beginOperation(t, routes, BeginInput{
|
||||
NowMs: 1,
|
||||
ConnectionID: "carrier_sfu",
|
||||
PublicationGeneration: "publication_generation",
|
||||
PublicationConnectionID: "publication_connection",
|
||||
Reservation: sfuCreateOverlap("carrier_subscription", "publication_resource", "publication_overlap"),
|
||||
})
|
||||
eq(t, routes.CandidateReady(CandidateGuard{
|
||||
ChildPeerID: carrier.ChildPeerID,
|
||||
ChildSessionID: carrier.ChildSessionID,
|
||||
Revision: carrier.Current.Revision,
|
||||
ConnectionID: "carrier_sfu",
|
||||
}, 2, nil, CandidateProof{}).Accepted, true)
|
||||
|
||||
demand := must(t, routes.Reconcile(3).Operation)
|
||||
eq(t, demand.ChildPeerID, A)
|
||||
eq(t, demand.DemandPeerID, A)
|
||||
eqSlice(t, tuples(demand), []CandidateTuple{sfuTuple(PublicationReuse)})
|
||||
beginCandidate(t, routes, BeginInput{NowMs: 4, ConnectionID: "demand_sfu_reuse", Reservation: sfuReuse("demand_subscription")})
|
||||
eqSlice(t, routes.OperationExpired(demand.DeadlineAtMs).FailedPeerIDs, []string{A})
|
||||
|
||||
convergence := must(t, routes.Reconcile(demand.DeadlineAtMs+1).Operation)
|
||||
eq(t, convergence.ChildPeerID, carrierPeerID)
|
||||
eq(t, convergence.Reason, DemandDirectConvergence)
|
||||
eqSlice(t, tuples(convergence), []CandidateTuple{peerTuple(HOST)})
|
||||
directOp := beginOperation(t, routes, BeginInput{
|
||||
NowMs: demand.DeadlineAtMs + 2,
|
||||
ConnectionID: "carrier_direct",
|
||||
Reservation: directOverlap("carrier_direct_overlap"),
|
||||
})
|
||||
committed := routes.CandidateReady(CandidateGuard{
|
||||
ChildPeerID: directOp.ChildPeerID,
|
||||
ChildSessionID: directOp.ChildSessionID,
|
||||
Revision: directOp.Current.Revision,
|
||||
ConnectionID: "carrier_direct",
|
||||
}, demand.DeadlineAtMs+3, nil, CandidateProof{})
|
||||
eq(t, committed.Accepted, true)
|
||||
containsLabels(t, committed.Released, "carrier_subscription", "publication_resource")
|
||||
if routes.Snapshot().HostPublication != nil {
|
||||
t.Fatal("expected no host publication")
|
||||
}
|
||||
return routes, demand.DeadlineAtMs + 4
|
||||
}
|
||||
|
||||
func invalidateActiveSfuEdge(t *testing.T) *Controller {
|
||||
t.Helper()
|
||||
routes := newController(1, Options{SfuEnabled: true})
|
||||
addViewer(routes, A, 0, nil)
|
||||
routes.hydrateHostPublication("active_publication", res("publication_resource"), "")
|
||||
routes.hydrateEdge(A, sfuEdge("active_publication", "active_sfu_edge", "active_subscription"))
|
||||
eq(t, routes.InvalidateEdge(edgeGuard(A, "", routes.Snapshot().Revision, "active_sfu_edge"), nil), true)
|
||||
return routes
|
||||
}
|
||||
|
||||
type senderState struct {
|
||||
childPeerID string
|
||||
connectionID string
|
||||
state SenderQualityState
|
||||
acceptedAtMs int64
|
||||
parentPeerID string // default HOST
|
||||
senderIdentity string // default "<connectionId>-rtp\x00track"
|
||||
}
|
||||
|
||||
func observeSenderState(routes *Controller, input senderState) SenderQualityEvidenceResult {
|
||||
parentPeerID := input.parentPeerID
|
||||
if parentPeerID == "" {
|
||||
parentPeerID = HOST
|
||||
}
|
||||
identity := input.senderIdentity
|
||||
if identity == "" {
|
||||
identity = input.connectionID + "-rtp\x00track"
|
||||
}
|
||||
return routes.ObserveSenderQualityEvidence(senderSample(
|
||||
parentPeerID, input.childPeerID, routes.Snapshot().Revision,
|
||||
input.connectionID, identity, input.state, input.acceptedAtMs), nil)
|
||||
}
|
||||
|
||||
// senderSample is the explicit observeSenderQualityEvidence literal the tests
|
||||
// build (sampleTimestampMs == acceptedAtMs).
|
||||
func senderSample(parentPeerID, childPeerID string, revision int64, connectionID, identity string, state SenderQualityState, at int64) SenderQualityEvidenceInput {
|
||||
return SenderQualityEvidenceInput{
|
||||
ParentPeerID: parentPeerID,
|
||||
ParentSessionID: parentSession(parentPeerID),
|
||||
ChildPeerID: childPeerID,
|
||||
RouteRevision: revision,
|
||||
ConnectionID: connectionID,
|
||||
SenderIdentity: &identity,
|
||||
SampleTimestampMs: ms(at),
|
||||
State: state,
|
||||
AcceptedAtMs: at,
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSample is the `state: "unknown", senderIdentity: null, sampleTimestampMs: null` literal.
|
||||
func unknownSample(parentPeerID, childPeerID string, revision int64, connectionID string, at int64) SenderQualityEvidenceInput {
|
||||
return SenderQualityEvidenceInput{
|
||||
ParentPeerID: parentPeerID,
|
||||
ParentSessionID: parentSession(parentPeerID),
|
||||
ChildPeerID: childPeerID,
|
||||
RouteRevision: revision,
|
||||
ConnectionID: connectionID,
|
||||
State: SenderQualityUnknown,
|
||||
AcceptedAtMs: at,
|
||||
}
|
||||
}
|
||||
|
||||
func observePersistentDegraded(t *testing.T, routes *Controller, childPeerID, connectionID string, acceptedAtMs int64) {
|
||||
t.Helper()
|
||||
eq(t, observeSenderState(routes, senderState{
|
||||
childPeerID: childPeerID, connectionID: connectionID, state: SenderQualityHealthy, acceptedAtMs: acceptedAtMs,
|
||||
}).Accepted, true)
|
||||
for offset := int64(1); offset <= 3; offset++ {
|
||||
eq(t, observeSenderState(routes, senderState{
|
||||
childPeerID: childPeerID, connectionID: connectionID, state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs + offset,
|
||||
}).Accepted, true)
|
||||
}
|
||||
}
|
||||
|
||||
func publisherSample(generation string, revision int64, state SenderQualityState, at int64) SfuPublisherQualityEvidenceInput {
|
||||
return SfuPublisherQualityEvidenceInput{
|
||||
HostPeerID: HOST,
|
||||
HostSessionID: "host_session",
|
||||
PublicationGeneration: generation,
|
||||
RouteRevision: revision,
|
||||
State: state,
|
||||
SampleTimestampMs: ms(at),
|
||||
AcceptedAtMs: at,
|
||||
}
|
||||
}
|
||||
|
||||
func observePersistentSfuHealthy(t *testing.T, routes *Controller, generation string, revision, acceptedAtMs int64) SenderQualityEvidenceResult {
|
||||
t.Helper()
|
||||
var result SenderQualityEvidenceResult
|
||||
for offset := int64(0); offset < 3; offset++ {
|
||||
result = routes.ObserveSfuPublisherQualityEvidence(publisherSample(generation, revision, SenderQualityHealthy, acceptedAtMs+offset), nil)
|
||||
eq(t, result.Accepted, true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func qualityMetrics() protocol.ViewerQualityEvidenceMetrics {
|
||||
return protocol.ViewerQualityEvidenceMetrics{
|
||||
FramesDecodedDelta: optInt(120),
|
||||
FreezeCountDelta: optInt(1),
|
||||
FreezeDurationMsDelta: optNum(250),
|
||||
PauseCountDelta: optInt(0),
|
||||
PauseDurationMsDelta: optNum(0),
|
||||
}
|
||||
}
|
||||
|
||||
func peerUpstream(peerID string) QualityUpstream {
|
||||
return QualityUpstream{Kind: UpstreamPeer, PeerID: peerID}
|
||||
}
|
||||
|
||||
func sfuUpstream() QualityUpstream { return QualityUpstream{Kind: UpstreamSfu} }
|
||||
|
||||
func qualityWindow(childPeerID string, revision int64, connectionID string, upstream QualityUpstream, epoch, at int64, metrics protocol.ViewerQualityEvidenceMetrics) RouteQualityEvidenceInput {
|
||||
return RouteQualityEvidenceInput{
|
||||
ChildPeerID: childPeerID,
|
||||
ChildSessionID: sessionOf(childPeerID),
|
||||
RouteRevision: revision,
|
||||
ConnectionID: connectionID,
|
||||
Upstream: upstream,
|
||||
PresentationEpoch: epoch,
|
||||
WindowMs: 2_000,
|
||||
Metrics: metrics,
|
||||
AcceptedAtMs: at,
|
||||
}
|
||||
}
|
||||
|
||||
// preparePeer ports the describe("bounded NAT candidate acquisition") helper.
|
||||
func preparePeer(t *testing.T, routes *Controller, nowMs int64, id string) (operation, prepared *OperationSnapshot, guard CandidateGuard) {
|
||||
t.Helper()
|
||||
operation = must(t, routes.Reconcile(nowMs).Operation)
|
||||
prepared = beginOperation(t, routes, BeginInput{NowMs: nowMs, ConnectionID: id, Reservation: direct()})
|
||||
guard = CandidateGuard{
|
||||
ChildPeerID: prepared.ChildPeerID,
|
||||
ChildSessionID: prepared.ChildSessionID,
|
||||
Revision: prepared.Current.Revision,
|
||||
ConnectionID: id,
|
||||
}
|
||||
return operation, prepared, guard
|
||||
}
|
||||
|
||||
func attemptCurrent(t *testing.T, operation *OperationSnapshot) int {
|
||||
t.Helper()
|
||||
if operation.Current == nil || operation.Current.ConnectionAttempt == nil {
|
||||
t.Fatal("expected a connection attempt")
|
||||
}
|
||||
return operation.Current.ConnectionAttempt.Current
|
||||
}
|
||||
|
||||
// --- assertions -----------------------------------------------------------
|
||||
|
||||
func eq[T comparable](t *testing.T, got, want T) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func eqSlice[T comparable](t *testing.T, got, want []T) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func eqDeep(t *testing.T, got, want any) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// eqLabels ports `expect(released).toEqual([...])`: exact labels in order.
|
||||
func eqLabels(t *testing.T, got []*Resource, want ...string) {
|
||||
t.Helper()
|
||||
eqSlice(t, labels(got), want)
|
||||
}
|
||||
|
||||
// containsLabels ports `expect(released).toEqual(expect.arrayContaining([...]))`.
|
||||
func containsLabels(t *testing.T, got []*Resource, want ...string) {
|
||||
t.Helper()
|
||||
have := labels(got)
|
||||
for _, label := range want {
|
||||
if !slices.Contains(have, label) {
|
||||
t.Fatalf("released %v does not contain %q", have, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expectTransition ports toEqual on an endpoint transition without retire;
|
||||
// producer "" means the TS object omitted producerPeerId.
|
||||
func expectTransition(t *testing.T, got EndpointTransition, kind TransitionKind, producer string) {
|
||||
t.Helper()
|
||||
eq(t, got.Kind, kind)
|
||||
switch {
|
||||
case producer == "" && got.ProducerPeerID != nil:
|
||||
t.Fatalf("expected no producer, got %q", *got.ProducerPeerID)
|
||||
case producer != "" && (got.ProducerPeerID == nil || *got.ProducerPeerID != producer):
|
||||
t.Fatalf("expected producer %q, got %v", producer, got.ProducerPeerID)
|
||||
}
|
||||
if kind != TransitionBoundedGap && got.Retire != nil {
|
||||
t.Fatalf("expected no retirement for %q transition", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func expectPanic(t *testing.T, want string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
t.Helper()
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatalf("expected a panic containing %q", want)
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(recovered), want) {
|
||||
t.Fatalf("panic %v does not contain %q", recovered, want)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,270 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// candidateOpportunityBase ports 2900: the NUL-joined ledger key. A session
|
||||
// change erases the key because the session is part of it.
|
||||
func (c *Controller) candidateOpportunityBase(childPeerID string, plan CandidatePlan) string {
|
||||
childSessionID := ""
|
||||
if child, ok := c.participants.Get(childPeerID); ok {
|
||||
childSessionID = child.sessionID
|
||||
}
|
||||
if plan.Tuple.Kind == UpstreamPeer {
|
||||
parentSessionID := ""
|
||||
if parent, ok := c.participants.Get(plan.Tuple.ParentPeerID); ok {
|
||||
parentSessionID = parent.sessionID
|
||||
}
|
||||
return tupleKey(plan.Tuple) + "\x00" + childSessionID + "\x00" + parentSessionID
|
||||
}
|
||||
hostSessionID := ""
|
||||
if host, ok := c.participants.Get(c.hostPeerID); ok {
|
||||
hostSessionID = host.sessionID
|
||||
}
|
||||
return sfuOpportunityPrefix + childSessionID + "\x00" + hostSessionID
|
||||
}
|
||||
|
||||
// candidateOpportunityAvailable ports 2823.
|
||||
func (c *Controller) candidateOpportunityAvailable(childPeerID string, plan CandidatePlan) bool {
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
if child == nil {
|
||||
return true
|
||||
}
|
||||
consumed, ok := child.consumedCandidateOpportunities.Get(c.candidateOpportunityBase(childPeerID, plan))
|
||||
return !ok ||
|
||||
endpointTransitionRank(plan.EndpointTransition) < consumed.rank ||
|
||||
(c.natPredictionEnabled &&
|
||||
plan.Tuple.Kind == UpstreamPeer &&
|
||||
!consumed.exhausted &&
|
||||
consumed.startedAttempts < maxNatConnectionAttempts)
|
||||
}
|
||||
|
||||
// startCandidateOpportunity ports 2842: counts one NAT attempt for a peer
|
||||
// candidate of an availability or direct-convergence operation.
|
||||
func (c *Controller) startCandidateOpportunity(op *operation, plan CandidatePlan) *ConnectionAttemptProgress {
|
||||
if !c.natPredictionEnabled ||
|
||||
plan.Tuple.Kind != UpstreamPeer ||
|
||||
(!isAvailabilityOperation(op.reason) && op.reason != DemandDirectConvergence) {
|
||||
return nil
|
||||
}
|
||||
child, _ := c.participants.Get(op.childPeerID)
|
||||
opportunities := &child.consumedCandidateOpportunities
|
||||
key := c.candidateOpportunityBase(op.childPeerID, plan)
|
||||
rank := endpointTransitionRank(plan.EndpointTransition)
|
||||
previous, ok := opportunities.Get(key)
|
||||
startedAttempts := 1
|
||||
if ok && previous.rank <= rank {
|
||||
startedAttempts = previous.startedAttempts + 1
|
||||
}
|
||||
newRank := rank
|
||||
if ok {
|
||||
newRank = min(previous.rank, rank)
|
||||
}
|
||||
// The new entry has no `exhausted` (2859-2862).
|
||||
opportunities.Set(key, candidateOpportunity{rank: newRank, startedAttempts: startedAttempts})
|
||||
op.peerAttemptStarted = true
|
||||
return &ConnectionAttemptProgress{Current: startedAttempts, Total: maxNatConnectionAttempts}
|
||||
}
|
||||
|
||||
// peerAttemptsStarted ports 2867.
|
||||
func (c *Controller) peerAttemptsStarted(childPeerID, parentPeerID string) int {
|
||||
if !c.natPredictionEnabled {
|
||||
return 0
|
||||
}
|
||||
plan := c.planCandidate(childPeerID, CandidateTuple{Kind: UpstreamPeer, ParentPeerID: parentPeerID, Transport: TransportDirect})
|
||||
if plan == nil {
|
||||
return 0
|
||||
}
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
if child == nil {
|
||||
return 0
|
||||
}
|
||||
opportunity, ok := child.consumedCandidateOpportunities.Get(c.candidateOpportunityBase(childPeerID, *plan))
|
||||
if ok && opportunity.rank <= endpointTransitionRank(plan.EndpointTransition) {
|
||||
return opportunity.startedAttempts
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// viewerAttemptsStarted ports 2880: the sum over every peer key.
|
||||
func (c *Controller) viewerAttemptsStarted(peerID string) int {
|
||||
if !c.natPredictionEnabled {
|
||||
return 0
|
||||
}
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if current == nil {
|
||||
return 0
|
||||
}
|
||||
started := 0
|
||||
for key, opportunity := range current.consumedCandidateOpportunities.All() {
|
||||
if strings.HasPrefix(key, "peer:") {
|
||||
started += opportunity.startedAttempts
|
||||
}
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
// hasRemainingNatOpportunity ports 2890.
|
||||
func (c *Controller) hasRemainingNatOpportunity(op *operation) bool {
|
||||
if !isAvailabilityOperation(op.reason) || !op.peerAttemptStarted {
|
||||
return false
|
||||
}
|
||||
for _, plan := range c.buildCandidates(op.childPeerID, false) {
|
||||
if plan.Tuple.Kind == UpstreamPeer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// consumeCandidateOpportunity ports 2916: exhausts the key at its best rank.
|
||||
func (c *Controller) consumeCandidateOpportunity(childPeerID string, plan CandidatePlan) {
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
if child == nil {
|
||||
return
|
||||
}
|
||||
key := c.candidateOpportunityBase(childPeerID, plan)
|
||||
rank := endpointTransitionRank(plan.EndpointTransition)
|
||||
previous, ok := child.consumedCandidateOpportunities.Get(key)
|
||||
entry := candidateOpportunity{rank: rank, exhausted: true}
|
||||
if ok {
|
||||
entry.rank = min(previous.rank, rank)
|
||||
entry.startedAttempts = previous.startedAttempts
|
||||
}
|
||||
child.consumedCandidateOpportunities.Set(key, entry)
|
||||
}
|
||||
|
||||
// qualityCandidateOpportunityAvailable ports 2934.
|
||||
func (c *Controller) qualityCandidateOpportunityAvailable(childPeerID string, plan CandidatePlan) bool {
|
||||
observation := c.senderQualityObservations[childPeerID]
|
||||
if observation == nil {
|
||||
return true
|
||||
}
|
||||
consumed, ok := observation.consumedQualityCandidates.Get(c.candidateOpportunityBase(childPeerID, plan))
|
||||
return !ok || endpointTransitionRank(plan.EndpointTransition) < consumed
|
||||
}
|
||||
|
||||
// consumeQualityCandidateOpportunity ports 2949.
|
||||
func (c *Controller) consumeQualityCandidateOpportunity(op *operation, plan *CandidatePlan) {
|
||||
if op.reason != DemandQualityConvergence || plan == nil {
|
||||
return
|
||||
}
|
||||
observation := c.currentQualityOperationObservation(op)
|
||||
if observation == nil {
|
||||
return
|
||||
}
|
||||
consumeOpportunity(observation.consumedQualityCandidates,
|
||||
c.candidateOpportunityBase(op.childPeerID, *plan),
|
||||
endpointTransitionRank(plan.EndpointTransition))
|
||||
}
|
||||
|
||||
// consumeQualityOperationCandidates ports 2967.
|
||||
func (c *Controller) consumeQualityOperationCandidates(op *operation) {
|
||||
for index := range op.candidates {
|
||||
candidate := op.candidates[index]
|
||||
c.consumeQualityCandidateOpportunity(op, &candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// consumeActiveEdgeOpportunity ports 2975: an invalidated active edge
|
||||
// exhausts its exact tuple at rank 0.
|
||||
func (c *Controller) consumeActiveEdgeOpportunity(child *participant, edge *CommittedEdge) {
|
||||
if edge.Kind == UpstreamPeer {
|
||||
key := "peer:" + edge.ParentPeerID + "\x00" + child.sessionID + "\x00" + edge.ParentSessionID
|
||||
previous, _ := child.consumedCandidateOpportunities.Get(key)
|
||||
child.consumedCandidateOpportunities.Set(key, candidateOpportunity{
|
||||
rank: 0,
|
||||
startedAttempts: previous.startedAttempts,
|
||||
exhausted: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.consumeCandidateOpportunity(child.peerID, CandidatePlan{
|
||||
Tuple: CandidateTuple{Kind: UpstreamSfu, Publication: PublicationReuse},
|
||||
EndpointTransition: EndpointTransition{Kind: TransitionNone},
|
||||
})
|
||||
}
|
||||
|
||||
// consumeAvailabilityOperation ports 2993.
|
||||
func (c *Controller) consumeAvailabilityOperation(op *operation) {
|
||||
if !isAvailabilityOperation(op.reason) {
|
||||
return
|
||||
}
|
||||
for _, plan := range op.candidates {
|
||||
if op.peerAttemptStarted && plan.Tuple.Kind == UpstreamPeer {
|
||||
continue
|
||||
}
|
||||
c.consumeCandidateOpportunity(op.childPeerID, plan)
|
||||
}
|
||||
}
|
||||
|
||||
// consumeCurrentAvailabilityOpportunity ports 3003.
|
||||
func (c *Controller) consumeCurrentAvailabilityOpportunity(op *operation) {
|
||||
if !isAvailabilityOperation(op.reason) {
|
||||
return
|
||||
}
|
||||
plan := op.planAt(op.cursor)
|
||||
if op.current != nil && op.current.connectionAttempt != nil && plan != nil && plan.Tuple.Kind == UpstreamPeer {
|
||||
return
|
||||
}
|
||||
if plan != nil {
|
||||
c.consumeCandidateOpportunity(op.childPeerID, *plan)
|
||||
}
|
||||
}
|
||||
|
||||
// clearOpportunitiesForSessionChange ports 4561 (§4.1 #5, §4.5 #47): keys
|
||||
// are snapshotted before deletion, which JS defines as safe in place.
|
||||
func (c *Controller) clearOpportunitiesForSessionChange(peerID string, role protocol.Role) {
|
||||
if current, ok := c.participants.Get(peerID); ok {
|
||||
current.consumedCandidateOpportunities.Clear()
|
||||
current.availabilityExhausted = false
|
||||
current.bootstrapFailureReported = false
|
||||
}
|
||||
parentPrefix := "peer:" + peerID + "\x00"
|
||||
for _, child := range c.participants.Values() {
|
||||
for _, key := range child.consumedCandidateOpportunities.Keys() {
|
||||
if strings.HasPrefix(key, parentPrefix) ||
|
||||
(role == protocol.RoleHost && strings.HasPrefix(key, sfuOpportunityPrefix)) {
|
||||
child.consumedCandidateOpportunities.Delete(key)
|
||||
}
|
||||
}
|
||||
if role == protocol.RoleHost {
|
||||
child.bootstrapFailureReported = false
|
||||
}
|
||||
}
|
||||
if role == protocol.RoleHost {
|
||||
c.consumedSfuBootstrapOpportunities.Clear()
|
||||
return
|
||||
}
|
||||
carrierPrefix := peerID + "\x00"
|
||||
for _, key := range c.consumedSfuBootstrapOpportunities.Keys() {
|
||||
if strings.HasPrefix(key, carrierPrefix) {
|
||||
c.consumedSfuBootstrapOpportunities.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearSfuCandidateOpportunities ports 4597.
|
||||
func (c *Controller) clearSfuCandidateOpportunities() {
|
||||
for _, current := range c.participants.Values() {
|
||||
for _, key := range current.consumedCandidateOpportunities.Keys() {
|
||||
if strings.HasPrefix(key, sfuOpportunityPrefix) {
|
||||
current.consumedCandidateOpportunities.Delete(key)
|
||||
}
|
||||
}
|
||||
current.bootstrapFailureReported = false
|
||||
}
|
||||
c.consumedSfuBootstrapOpportunities.Clear()
|
||||
}
|
||||
|
||||
// consumeOpportunity ports 5011: keeps the best (lowest) rank.
|
||||
func consumeOpportunity(consumed *ordered.Map[string, int], base string, rank int) {
|
||||
previous, ok := consumed.Get(base)
|
||||
if !ok || rank < previous {
|
||||
consumed.Set(base, rank)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
)
|
||||
|
||||
// ObserveQualityEvidence aggregates one receiver-side window onto the
|
||||
// child's committed edge: rejected on any mismatch, accepted when consumed
|
||||
// without aggregation, observed when aggregated.
|
||||
func (c *Controller) ObserveQualityEvidence(input RouteQualityEvidenceInput) RouteQualityEvidenceResult {
|
||||
if c.paused ||
|
||||
input.RouteRevision != c.revision ||
|
||||
!isSafeInteger(input.PresentationEpoch) ||
|
||||
input.PresentationEpoch < 0 ||
|
||||
!isSafeInteger(input.WindowMs) ||
|
||||
input.WindowMs <= 0 ||
|
||||
!isSafeInteger(input.AcceptedAtMs) ||
|
||||
input.AcceptedAtMs < 0 {
|
||||
return QualityEvidenceRejected
|
||||
}
|
||||
child, _ := c.participants.Get(input.ChildPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(input.ChildPeerID)
|
||||
if child == nil ||
|
||||
!child.sessionIs(input.ChildSessionID) ||
|
||||
edge == nil ||
|
||||
!edge.Usable ||
|
||||
!edge.PhysicalActive ||
|
||||
edge.ChildSessionID != input.ChildSessionID ||
|
||||
edge.ConnectionID != input.ConnectionID ||
|
||||
!qualityUpstreamMatches(edge, input.Upstream) ||
|
||||
!c.sourceUsableForQuality(edge) {
|
||||
return QualityEvidenceRejected
|
||||
}
|
||||
|
||||
upstreamPeerID := ""
|
||||
if input.Upstream.Kind == UpstreamPeer {
|
||||
upstreamPeerID = input.Upstream.PeerID
|
||||
}
|
||||
observation := c.qualityObservations[input.ChildPeerID]
|
||||
sameEdgeObservation := observation != nil &&
|
||||
observation.childSessionID == input.ChildSessionID &&
|
||||
observation.upstreamKind == input.Upstream.Kind &&
|
||||
observation.upstreamPeerID == upstreamPeerID &&
|
||||
observation.connectionID == input.ConnectionID
|
||||
if sameEdgeObservation && input.PresentationEpoch < observation.presentationEpoch {
|
||||
return QualityEvidenceRejected
|
||||
}
|
||||
if !sameEdgeObservation || observation.presentationEpoch != input.PresentationEpoch {
|
||||
observation = &routeQualityObservation{
|
||||
childSessionID: input.ChildSessionID,
|
||||
upstreamKind: input.Upstream.Kind,
|
||||
upstreamPeerID: upstreamPeerID,
|
||||
connectionID: input.ConnectionID,
|
||||
presentationEpoch: input.PresentationEpoch,
|
||||
lastAcceptedAtMs: input.AcceptedAtMs,
|
||||
}
|
||||
c.qualityObservations[input.ChildPeerID] = observation
|
||||
}
|
||||
|
||||
metrics := input.Metrics
|
||||
if metrics.FramesDecodedDelta == nil || *metrics.FramesDecodedDelta <= 0 {
|
||||
return QualityEvidenceAccepted
|
||||
}
|
||||
hadFreshDecodedProgress := observation.lastDecodedProgressAtMs != nil &&
|
||||
input.AcceptedAtMs < *observation.lastDecodedProgressAtMs+qualityEvidenceExpiryMs
|
||||
decodedAt := input.AcceptedAtMs
|
||||
observation.lastDecodedProgressAtMs = &decodedAt
|
||||
if !hadFreshDecodedProgress && edge.Kind == UpstreamSfu {
|
||||
c.touchFacts()
|
||||
}
|
||||
if metrics.FreezeCountDelta == nil ||
|
||||
metrics.FreezeDurationMsDelta == nil ||
|
||||
metrics.PauseCountDelta == nil ||
|
||||
metrics.PauseDurationMsDelta == nil {
|
||||
return QualityEvidenceAccepted
|
||||
}
|
||||
observation.lastAcceptedAtMs = input.AcceptedAtMs
|
||||
if _, pending := c.qualityBaselinesPending[input.ChildPeerID]; pending {
|
||||
delete(c.qualityBaselinesPending, input.ChildPeerID)
|
||||
return QualityEvidenceAccepted
|
||||
}
|
||||
|
||||
observation.eligibleWindows = safeAdd(observation.eligibleWindows, 1)
|
||||
observation.eligibleDurationMs = safeAdd(observation.eligibleDurationMs, float64(input.WindowMs))
|
||||
if *metrics.FreezeCountDelta > 0 {
|
||||
observation.freezeWindows = safeAdd(observation.freezeWindows, 1)
|
||||
}
|
||||
observation.freezeCount = safeAdd(observation.freezeCount, float64(*metrics.FreezeCountDelta))
|
||||
observation.freezeDurationMs = safeAdd(observation.freezeDurationMs, float64(*metrics.FreezeDurationMsDelta))
|
||||
observation.pauseCount = safeAdd(observation.pauseCount, float64(*metrics.PauseCountDelta))
|
||||
observation.pauseDurationMs = safeAdd(observation.pauseDurationMs, float64(*metrics.PauseDurationMsDelta))
|
||||
return QualityEvidenceObserved
|
||||
}
|
||||
|
||||
// senderAccepted is the `accepted: true, committed: false` literal.
|
||||
func (c *Controller) senderAccepted(released []*Resource) SenderQualityEvidenceResult {
|
||||
return SettleResult{
|
||||
Accepted: true,
|
||||
Committed: false,
|
||||
FailedPeerIDs: []string{},
|
||||
ActiveRevision: c.revision,
|
||||
Released: released,
|
||||
}
|
||||
}
|
||||
|
||||
// senderRejected is the `rejected()` literal.
|
||||
func (c *Controller) senderRejected() SenderQualityEvidenceResult {
|
||||
return SettleResult{
|
||||
Accepted: false,
|
||||
Committed: false,
|
||||
FailedPeerIDs: []string{},
|
||||
ActiveRevision: c.revision,
|
||||
Released: []*Resource{},
|
||||
}
|
||||
}
|
||||
|
||||
// ObserveSenderQualityEvidence records a parent's health sample for a child,
|
||||
// settling the live quality/root candidate when the sample names it; a nil
|
||||
// commitReservation always commits. Requires QualityConvergenceEnabled.
|
||||
func (c *Controller) ObserveSenderQualityEvidence(input SenderQualityEvidenceInput, commitReservation func(CandidateReservation) bool) SenderQualityEvidenceResult {
|
||||
if commitReservation == nil {
|
||||
commitReservation = func(CandidateReservation) bool { return true }
|
||||
}
|
||||
if !c.qualityConvergenceEnabled ||
|
||||
c.paused ||
|
||||
!isSafeInteger(input.AcceptedAtMs) ||
|
||||
input.AcceptedAtMs < 0 {
|
||||
return c.senderRejected()
|
||||
}
|
||||
parent, _ := c.participants.Get(input.ParentPeerID)
|
||||
child, _ := c.participants.Get(input.ChildPeerID)
|
||||
if parent == nil || parent.sessionID == "" ||
|
||||
!parent.sessionIs(input.ParentSessionID) ||
|
||||
child == nil || child.sessionID == "" ||
|
||||
child.departureConfirmed ||
|
||||
parent.departureConfirmed {
|
||||
return c.senderRejected()
|
||||
}
|
||||
|
||||
op := c.operation
|
||||
var att *attempt
|
||||
if op != nil {
|
||||
att = op.current
|
||||
}
|
||||
if op != nil &&
|
||||
(op.reason == DemandRootConvergence || op.reason == DemandQualityConvergence) &&
|
||||
att != nil && att.tuple.Kind == UpstreamPeer &&
|
||||
op.childPeerID == input.ChildPeerID &&
|
||||
op.childSessionID == child.sessionID &&
|
||||
att.tuple.ParentPeerID == input.ParentPeerID &&
|
||||
att.parentSessionID == input.ParentSessionID &&
|
||||
att.connectionID == input.ConnectionID &&
|
||||
att.revision == input.RouteRevision {
|
||||
senderIdentity := ""
|
||||
if input.SenderIdentity != nil {
|
||||
senderIdentity = *input.SenderIdentity
|
||||
}
|
||||
return c.settleCandidateQuality(op, att, input.State, input.AcceptedAtMs, senderIdentity, input.SampleTimestampMs, commitReservation)
|
||||
}
|
||||
|
||||
edge, _ := c.upstreamByViewer.Get(input.ChildPeerID)
|
||||
if input.RouteRevision != c.revision ||
|
||||
edge == nil || edge.Kind != UpstreamPeer ||
|
||||
!edge.Usable ||
|
||||
!edge.PhysicalActive ||
|
||||
edge.ParentPeerID != input.ParentPeerID ||
|
||||
edge.ParentSessionID != input.ParentSessionID ||
|
||||
edge.ChildSessionID != child.sessionID ||
|
||||
edge.ConnectionID != input.ConnectionID {
|
||||
return c.senderRejected()
|
||||
}
|
||||
previous := c.senderQualityObservations[input.ChildPeerID]
|
||||
sameConnectionIdentity := previous != nil &&
|
||||
previous.childSessionID == child.sessionID &&
|
||||
previous.parentPeerID == input.ParentPeerID &&
|
||||
previous.parentSessionID == input.ParentSessionID &&
|
||||
previous.connectionID == input.ConnectionID
|
||||
if input.State == SenderQualityUnknown {
|
||||
if previous != nil && previous.state != SenderQualityUnknown {
|
||||
c.debug("sender-quality-reset",
|
||||
"parent", c.debugPeer(input.ParentPeerID),
|
||||
"child", c.debugPeer(input.ChildPeerID))
|
||||
}
|
||||
if previous != nil && sameConnectionIdentity {
|
||||
// `{...previous}` keeps the same consumed ledger pointer.
|
||||
reset := *previous
|
||||
reset.state = SenderQualityUnknown
|
||||
reset.consecutiveDegradedWindows = 0
|
||||
reset.lastAcceptedAtMs = input.AcceptedAtMs
|
||||
c.senderQualityObservations[input.ChildPeerID] = &reset
|
||||
} else {
|
||||
delete(c.senderQualityObservations, input.ChildPeerID)
|
||||
}
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
if input.SenderIdentity == nil || *input.SenderIdentity == "" || input.SampleTimestampMs == nil {
|
||||
return c.senderRejected()
|
||||
}
|
||||
senderIdentity := *input.SenderIdentity
|
||||
sampleTimestampMs := *input.SampleTimestampMs
|
||||
sameSenderIdentity := sameConnectionIdentity && previous.senderIdentity == senderIdentity
|
||||
if sameSenderIdentity && sampleTimestampMs <= previous.lastSampleTimestampMs {
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
if block, ok := c.senderQualityRegenerationBlocks[input.ChildPeerID]; ok &&
|
||||
!senderQualityRegenerationBlockMatches(block, child.sessionID, input, senderIdentity) {
|
||||
// A new connection or sender identity is a new generation. Do not carry
|
||||
// a same-edge regeneration latch across that identity fence.
|
||||
delete(c.senderQualityRegenerationBlocks, input.ChildPeerID)
|
||||
}
|
||||
if input.State == SenderQualityHealthy {
|
||||
delete(c.senderQualityRegenerationBlocks, input.ChildPeerID)
|
||||
}
|
||||
sameFreshSenderIdentity := sameSenderIdentity &&
|
||||
input.AcceptedAtMs < previous.lastAcceptedAtMs+qualityEvidenceExpiryMs
|
||||
if input.State == SenderQualityHealthy {
|
||||
delete(c.senderQualityBaselines, input.ChildPeerID)
|
||||
}
|
||||
var consecutiveDegradedWindows int64
|
||||
if input.State == SenderQualityDegraded {
|
||||
consecutiveDegradedWindows = 1
|
||||
if sameFreshSenderIdentity && previous.state == SenderQualityDegraded {
|
||||
consecutiveDegradedWindows = safeAdd(previous.consecutiveDegradedWindows, 1)
|
||||
}
|
||||
}
|
||||
consumed := &ordered.Map[string, int]{}
|
||||
if sameSenderIdentity && input.State != SenderQualityHealthy {
|
||||
consumed = previous.consumedQualityCandidates
|
||||
}
|
||||
c.senderQualityObservations[input.ChildPeerID] = &senderQualityObservation{
|
||||
childSessionID: child.sessionID,
|
||||
parentPeerID: input.ParentPeerID,
|
||||
parentSessionID: input.ParentSessionID,
|
||||
connectionID: input.ConnectionID,
|
||||
senderIdentity: senderIdentity,
|
||||
state: input.State,
|
||||
consecutiveDegradedWindows: consecutiveDegradedWindows,
|
||||
lastSampleTimestampMs: sampleTimestampMs,
|
||||
lastAcceptedAtMs: input.AcceptedAtMs,
|
||||
consumedQualityCandidates: consumed,
|
||||
}
|
||||
if !sameFreshSenderIdentity ||
|
||||
previous.state != input.State ||
|
||||
consecutiveDegradedWindows == persistentDegradedWindows {
|
||||
c.debug("sender-quality-observed",
|
||||
"parent", c.debugPeer(input.ParentPeerID),
|
||||
"child", c.debugPeer(input.ChildPeerID),
|
||||
"state", input.State,
|
||||
"consecutiveDegradedWindows", consecutiveDegradedWindows)
|
||||
}
|
||||
if input.State == SenderQualityHealthy &&
|
||||
op != nil && op.reason == DemandQualityConvergence &&
|
||||
op.childPeerID == input.ChildPeerID {
|
||||
released := c.abortOperation(&input.AcceptedAtMs, RejectionAborted)
|
||||
return c.senderAccepted(released)
|
||||
}
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
|
||||
// senderQualityRegenerationBlockMatches ports 4985.
|
||||
func senderQualityRegenerationBlockMatches(block senderQualityIdentity, childSessionID string, input SenderQualityEvidenceInput, senderIdentity string) bool {
|
||||
return block.childSessionID == childSessionID &&
|
||||
block.parentPeerID == input.ParentPeerID &&
|
||||
block.parentSessionID == input.ParentSessionID &&
|
||||
block.connectionID == input.ConnectionID &&
|
||||
block.senderIdentity == senderIdentity
|
||||
}
|
||||
|
||||
// ObserveSfuPublisherQualityEvidence records a Host->SFU ingress sample,
|
||||
// settling a live sfu/reuse quality candidate; a nil commitReservation
|
||||
// always commits. Requires QualityConvergenceEnabled. Unlike the other two
|
||||
// observers it validates no numbers (TS asymmetry, map R13).
|
||||
func (c *Controller) ObserveSfuPublisherQualityEvidence(input SfuPublisherQualityEvidenceInput, commitReservation func(CandidateReservation) bool) SenderQualityEvidenceResult {
|
||||
if commitReservation == nil {
|
||||
commitReservation = func(CandidateReservation) bool { return true }
|
||||
}
|
||||
host, _ := c.participants.Get(c.hostPeerID)
|
||||
if !c.qualityConvergenceEnabled ||
|
||||
c.paused ||
|
||||
input.HostPeerID != c.hostPeerID ||
|
||||
host == nil || host.sessionID == "" ||
|
||||
!host.sessionIs(input.HostSessionID) {
|
||||
return c.senderRejected()
|
||||
}
|
||||
op := c.operation
|
||||
var att *attempt
|
||||
if op != nil {
|
||||
att = op.current
|
||||
}
|
||||
if op != nil && op.reason == DemandQualityConvergence &&
|
||||
att != nil && att.tuple.Kind == UpstreamSfu &&
|
||||
att.hostSessionID == input.HostSessionID &&
|
||||
att.publicationGeneration == input.PublicationGeneration &&
|
||||
att.revision == input.RouteRevision {
|
||||
return c.settleCandidateQuality(op, att, input.State, input.AcceptedAtMs, input.PublicationGeneration, input.SampleTimestampMs, commitReservation)
|
||||
}
|
||||
if input.RouteRevision != c.revision ||
|
||||
c.hostPublication == nil || !c.hostPublication.Usable ||
|
||||
!c.hostPublication.PhysicalActive ||
|
||||
c.hostPublication.HostSessionID != input.HostSessionID ||
|
||||
c.hostPublication.Generation != input.PublicationGeneration {
|
||||
return c.senderRejected()
|
||||
}
|
||||
if input.State == SenderQualityUnknown {
|
||||
if c.sfuPublisherQualityObservation != nil {
|
||||
c.debug("sfu-publisher-quality-reset")
|
||||
}
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
if input.SampleTimestampMs == nil {
|
||||
return c.senderRejected()
|
||||
}
|
||||
sampleTimestampMs := *input.SampleTimestampMs
|
||||
previous := c.sfuPublisherQualityObservation
|
||||
samePublication := previous != nil &&
|
||||
previous.hostSessionID == input.HostSessionID &&
|
||||
previous.publicationGeneration == input.PublicationGeneration
|
||||
if samePublication && sampleTimestampMs <= previous.lastSampleTimestampMs {
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
sameFreshPublication := samePublication &&
|
||||
input.AcceptedAtMs < previous.lastAcceptedAtMs+qualityEvidenceExpiryMs
|
||||
var consecutiveHealthyWindows int64
|
||||
if input.State == SenderQualityHealthy {
|
||||
consecutiveHealthyWindows = 1
|
||||
if sameFreshPublication && previous.state == SenderQualityHealthy {
|
||||
consecutiveHealthyWindows = safeAdd(previous.consecutiveHealthyWindows, 1)
|
||||
}
|
||||
}
|
||||
wasPersistentlyHealthy := sameFreshPublication &&
|
||||
previous.state == SenderQualityHealthy &&
|
||||
previous.consecutiveHealthyWindows >= persistentDegradedWindows
|
||||
c.sfuPublisherQualityObservation = &sfuPublisherQualityObservation{
|
||||
hostSessionID: input.HostSessionID,
|
||||
publicationGeneration: input.PublicationGeneration,
|
||||
state: input.State,
|
||||
consecutiveHealthyWindows: consecutiveHealthyWindows,
|
||||
lastSampleTimestampMs: sampleTimestampMs,
|
||||
lastAcceptedAtMs: input.AcceptedAtMs,
|
||||
}
|
||||
if !samePublication ||
|
||||
previous.state != input.State ||
|
||||
consecutiveHealthyWindows == persistentDegradedWindows {
|
||||
c.debug("sfu-publisher-quality-observed",
|
||||
"state", input.State,
|
||||
"consecutiveHealthyWindows", consecutiveHealthyWindows)
|
||||
}
|
||||
if input.State == SenderQualityHealthy &&
|
||||
consecutiveHealthyWindows >= persistentDegradedWindows &&
|
||||
!wasPersistentlyHealthy {
|
||||
c.touchFacts()
|
||||
}
|
||||
if op != nil && op.reason == DemandQualityConvergence &&
|
||||
att != nil && att.tuple.Kind == UpstreamSfu &&
|
||||
att.tuple.Publication == PublicationReuse &&
|
||||
att.hostSessionID == input.HostSessionID &&
|
||||
att.publicationGeneration == input.PublicationGeneration &&
|
||||
att.mediaReady {
|
||||
guard := CandidateGuard{
|
||||
ChildPeerID: op.childPeerID,
|
||||
ChildSessionID: op.childSessionID,
|
||||
Revision: att.revision,
|
||||
ConnectionID: att.connectionID,
|
||||
}
|
||||
var settled *SettleResult
|
||||
if input.State == SenderQualityDegraded {
|
||||
result := c.CandidateFailed(guard, input.AcceptedAtMs)
|
||||
settled = &result
|
||||
} else if consecutiveHealthyWindows >= persistentDegradedWindows {
|
||||
result := c.CandidateReady(guard, input.AcceptedAtMs, commitReservation, CandidateProof{})
|
||||
settled = &result
|
||||
}
|
||||
if settled != nil {
|
||||
return SettleResult{
|
||||
Accepted: settled.Accepted,
|
||||
Committed: settled.Committed,
|
||||
FailedPeerIDs: settled.FailedPeerIDs,
|
||||
ActiveRevision: settled.ActiveRevision,
|
||||
Released: settled.Released,
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
|
||||
// settleCandidateQuality ports 1428: folds a sender sample into the live
|
||||
// candidate and fails or commits it. senderIdentity "" is the TS null.
|
||||
func (c *Controller) settleCandidateQuality(op *operation, att *attempt, state SenderQualityState, acceptedAtMs int64, senderIdentity string, sampleTimestampMs *int64, commitReservation func(CandidateReservation) bool) SenderQualityEvidenceResult {
|
||||
if att.senderQualityState != state {
|
||||
c.debug("candidate-quality-observed",
|
||||
"child", c.debugPeer(op.childPeerID),
|
||||
"candidate", c.debugTuple(att.tuple),
|
||||
"state", state)
|
||||
}
|
||||
if state == SenderQualityUnknown {
|
||||
att.senderQualityState = ""
|
||||
att.senderQualityAcceptedAtMs = nil
|
||||
att.senderQualityConsecutiveHealthyWindows = 0
|
||||
att.senderQualityConsecutiveDegradedWindows = 0
|
||||
att.senderQualitySampleTimestampMs = nil
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
if senderIdentity == "" || sampleTimestampMs == nil {
|
||||
return c.senderRejected()
|
||||
}
|
||||
guard := CandidateGuard{
|
||||
ChildPeerID: op.childPeerID,
|
||||
ChildSessionID: op.childSessionID,
|
||||
Revision: att.revision,
|
||||
ConnectionID: att.connectionID,
|
||||
}
|
||||
if att.senderQualityIdentity != "" && att.senderQualityIdentity != senderIdentity {
|
||||
failed := c.CandidateFailed(guard, acceptedAtMs)
|
||||
return SettleResult{
|
||||
Accepted: failed.Accepted,
|
||||
Committed: failed.Committed,
|
||||
FailedPeerIDs: failed.FailedPeerIDs,
|
||||
ActiveRevision: failed.ActiveRevision,
|
||||
Released: failed.Released,
|
||||
}
|
||||
}
|
||||
if att.senderQualitySampleTimestampMs != nil && *sampleTimestampMs <= *att.senderQualitySampleTimestampMs {
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
sameFreshState := att.senderQualityAcceptedAtMs != nil &&
|
||||
acceptedAtMs < *att.senderQualityAcceptedAtMs+qualityEvidenceExpiryMs
|
||||
var consecutiveHealthyWindows int64
|
||||
if att.tuple.Kind == UpstreamSfu && state == SenderQualityHealthy {
|
||||
consecutiveHealthyWindows = 1
|
||||
if att.senderQualityState == SenderQualityHealthy && sameFreshState {
|
||||
consecutiveHealthyWindows = safeAdd(att.senderQualityConsecutiveHealthyWindows, 1)
|
||||
}
|
||||
} else if state == SenderQualityHealthy {
|
||||
consecutiveHealthyWindows = 1
|
||||
}
|
||||
var consecutiveDegradedWindows int64
|
||||
if state == SenderQualityDegraded {
|
||||
consecutiveDegradedWindows = 1
|
||||
if att.senderQualityState == SenderQualityDegraded && sameFreshState {
|
||||
consecutiveDegradedWindows = safeAdd(att.senderQualityConsecutiveDegradedWindows, 1)
|
||||
}
|
||||
}
|
||||
acceptedAt := acceptedAtMs
|
||||
sampleAt := *sampleTimestampMs
|
||||
att.senderQualityState = state
|
||||
att.senderQualityAcceptedAtMs = &acceptedAt
|
||||
att.senderQualityConsecutiveHealthyWindows = consecutiveHealthyWindows
|
||||
att.senderQualityConsecutiveDegradedWindows = consecutiveDegradedWindows
|
||||
att.senderQualitySampleTimestampMs = &sampleAt
|
||||
att.senderQualityIdentity = senderIdentity
|
||||
persistentQualityPeerDegradation := op.reason == DemandQualityConvergence &&
|
||||
att.tuple.Kind == UpstreamPeer &&
|
||||
consecutiveDegradedWindows >= persistentDegradedWindows
|
||||
var settled *SettleResult
|
||||
if state == SenderQualityDegraded &&
|
||||
(att.tuple.Kind != UpstreamPeer ||
|
||||
op.reason != DemandQualityConvergence ||
|
||||
persistentQualityPeerDegradation) {
|
||||
result := c.CandidateFailed(guard, acceptedAtMs)
|
||||
settled = &result
|
||||
} else if att.mediaReady &&
|
||||
(att.tuple.Kind != UpstreamSfu || consecutiveHealthyWindows >= persistentDegradedWindows) {
|
||||
result := c.CandidateReady(guard, acceptedAtMs, commitReservation, CandidateProof{})
|
||||
settled = &result
|
||||
}
|
||||
if settled == nil {
|
||||
return c.senderAccepted([]*Resource{})
|
||||
}
|
||||
return SettleResult{
|
||||
Accepted: true,
|
||||
Committed: settled.Committed,
|
||||
FailedPeerIDs: settled.FailedPeerIDs,
|
||||
ActiveRevision: settled.ActiveRevision,
|
||||
Released: settled.Released,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetSenderQuality drops the parent's sender observations, baselines and
|
||||
// regeneration latches and aborts an operation whose live candidate that
|
||||
// parent owns; empty unless the parent's session matches.
|
||||
func (c *Controller) ResetSenderQuality(parentPeerID, parentSessionID string, nowMs int64) []*Resource {
|
||||
parent, _ := c.participants.Get(parentPeerID)
|
||||
if parent == nil || !parent.sessionIs(parentSessionID) {
|
||||
return []*Resource{}
|
||||
}
|
||||
// Membership only (§4.5 #48): every affected child is deleted.
|
||||
children := make(map[string]struct{})
|
||||
for _, childPeerID := range c.childrenOf(parentPeerID) {
|
||||
children[childPeerID] = struct{}{}
|
||||
}
|
||||
for childPeerID, observation := range c.senderQualityObservations {
|
||||
if observation.parentPeerID == parentPeerID && observation.parentSessionID == parentSessionID {
|
||||
children[childPeerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for childPeerID := range children {
|
||||
delete(c.senderQualityObservations, childPeerID)
|
||||
// Reset means the next exact sender sequence is a new observation
|
||||
// window. A quality/root commit still installs the explicit rearm latch
|
||||
// below; a visibility or source reset must not deadlock a persistently
|
||||
// limited edge waiting for a healthy sample.
|
||||
delete(c.senderQualityBaselines, childPeerID)
|
||||
delete(c.senderQualityRegenerationBlocks, childPeerID)
|
||||
}
|
||||
if parentPeerID == c.hostPeerID {
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
}
|
||||
op := c.operation
|
||||
var att *attempt
|
||||
if op != nil {
|
||||
att = op.current
|
||||
}
|
||||
ownsCandidate := false
|
||||
if att != nil {
|
||||
if att.tuple.Kind == UpstreamPeer {
|
||||
ownsCandidate = (op.reason == DemandRootConvergence || op.reason == DemandQualityConvergence) &&
|
||||
att.tuple.ParentPeerID == parentPeerID &&
|
||||
att.parentSessionID == parentSessionID
|
||||
} else {
|
||||
ownsCandidate = operationRequiresNativeCandidateProof(op) &&
|
||||
parentPeerID == c.hostPeerID &&
|
||||
att.hostSessionID == parentSessionID
|
||||
}
|
||||
}
|
||||
if ownsCandidate {
|
||||
return c.abortOperation(&nowMs, RejectionAborted)
|
||||
}
|
||||
return []*Resource{}
|
||||
}
|
||||
|
||||
// senderQualityState ports 3012.
|
||||
func (c *Controller) senderQualityState(childPeerID string, edge *CommittedEdge, nowMs int64) SenderQualityState {
|
||||
if edge.Kind != UpstreamPeer {
|
||||
if c.sourceUsableForQuality(edge) {
|
||||
return SenderQualityHealthy
|
||||
}
|
||||
return SenderQualityUnknown
|
||||
}
|
||||
observation := c.senderQualityObservations[childPeerID]
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
if observation != nil &&
|
||||
nowMs < observation.lastAcceptedAtMs+qualityEvidenceExpiryMs &&
|
||||
child != nil && child.sessionIs(observation.childSessionID) &&
|
||||
edge.ChildSessionID == observation.childSessionID &&
|
||||
edge.ParentPeerID == observation.parentPeerID &&
|
||||
edge.ParentSessionID == observation.parentSessionID &&
|
||||
edge.ConnectionID == observation.connectionID &&
|
||||
c.sourceUsableForQuality(edge) {
|
||||
return observation.state
|
||||
}
|
||||
return SenderQualityUnknown
|
||||
}
|
||||
|
||||
// currentQualityOperationSource ports 3034.
|
||||
func (c *Controller) currentQualityOperationSource(childPeerID string) *senderQualityIdentity {
|
||||
child, _ := c.participants.Get(childPeerID)
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
observation := c.senderQualityObservations[childPeerID]
|
||||
if child == nil || child.sessionID == "" ||
|
||||
edge == nil || edge.Kind != UpstreamPeer ||
|
||||
observation == nil ||
|
||||
observation.childSessionID != child.sessionID ||
|
||||
observation.childSessionID != edge.ChildSessionID ||
|
||||
observation.parentPeerID != edge.ParentPeerID ||
|
||||
observation.parentSessionID != edge.ParentSessionID ||
|
||||
observation.connectionID != edge.ConnectionID {
|
||||
return nil
|
||||
}
|
||||
return &senderQualityIdentity{
|
||||
childSessionID: observation.childSessionID,
|
||||
parentPeerID: observation.parentPeerID,
|
||||
parentSessionID: observation.parentSessionID,
|
||||
connectionID: observation.connectionID,
|
||||
senderIdentity: observation.senderIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
// currentQualityOperationObservation ports 3061.
|
||||
func (c *Controller) currentQualityOperationObservation(op *operation) *senderQualityObservation {
|
||||
source := op.qualitySource
|
||||
current := c.currentQualityOperationSource(op.childPeerID)
|
||||
if source == nil || current == nil || *source != *current {
|
||||
return nil
|
||||
}
|
||||
return c.senderQualityObservations[op.childPeerID]
|
||||
}
|
||||
|
||||
// senderQualityRegenerationBlocked ports 3080.
|
||||
func (c *Controller) senderQualityRegenerationBlocked(childPeerID string, edge *CommittedEdge) bool {
|
||||
block, ok := c.senderQualityRegenerationBlocks[childPeerID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
observation := c.senderQualityObservations[childPeerID]
|
||||
if observation != nil && observation.senderIdentity != block.senderIdentity {
|
||||
delete(c.senderQualityRegenerationBlocks, childPeerID)
|
||||
return false
|
||||
}
|
||||
if block.childSessionID != edge.ChildSessionID ||
|
||||
block.parentPeerID != edge.ParentPeerID ||
|
||||
block.parentSessionID != edge.ParentSessionID ||
|
||||
block.connectionID != edge.ConnectionID {
|
||||
delete(c.senderQualityRegenerationBlocks, childPeerID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// candidateSenderPersistentlyDegraded ports 3105.
|
||||
func (c *Controller) candidateSenderPersistentlyDegraded(att *attempt, nowMs int64) bool {
|
||||
return att.senderQualityState == SenderQualityDegraded &&
|
||||
att.senderQualityAcceptedAtMs != nil &&
|
||||
nowMs < *att.senderQualityAcceptedAtMs+qualityEvidenceExpiryMs &&
|
||||
att.senderQualityConsecutiveDegradedWindows >= persistentDegradedWindows
|
||||
}
|
||||
|
||||
// sfuPublisherQualityState ports 3120.
|
||||
func (c *Controller) sfuPublisherQualityState(publicationGeneration string, nowMs int64) SenderQualityState {
|
||||
observation := c.sfuPublisherQualityObservation
|
||||
if observation != nil &&
|
||||
observation.publicationGeneration == publicationGeneration &&
|
||||
c.hostPublication != nil && observation.hostSessionID == c.hostPublication.HostSessionID &&
|
||||
nowMs < observation.lastAcceptedAtMs+qualityEvidenceExpiryMs {
|
||||
if observation.state == SenderQualityHealthy && observation.consecutiveHealthyWindows < persistentDegradedWindows {
|
||||
return SenderQualityUnknown
|
||||
}
|
||||
return observation.state
|
||||
}
|
||||
return SenderQualityUnknown
|
||||
}
|
||||
|
||||
// qualitySourcePathHealthy ports 3137.
|
||||
func (c *Controller) qualitySourcePathHealthy(peerID string, nowMs int64) bool {
|
||||
if peerID == c.hostPeerID {
|
||||
return true
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for {
|
||||
if _, visited := seen[peerID]; visited {
|
||||
return false
|
||||
}
|
||||
seen[peerID] = struct{}{}
|
||||
edge, _ := c.upstreamByViewer.Get(peerID)
|
||||
if edge == nil || !edge.Usable || !edge.PhysicalActive {
|
||||
return false
|
||||
}
|
||||
if edge.Kind == UpstreamSfu {
|
||||
return c.sourceUsableForQuality(edge) &&
|
||||
c.decodedProgressFresh(peerID, edge, nowMs) &&
|
||||
c.sfuPublisherQualityState(edge.PublicationGeneration, nowMs) == SenderQualityHealthy
|
||||
}
|
||||
if c.senderQualityState(peerID, edge, nowMs) != SenderQualityHealthy {
|
||||
return false
|
||||
}
|
||||
if edge.ParentPeerID == c.hostPeerID {
|
||||
return true
|
||||
}
|
||||
peerID = edge.ParentPeerID
|
||||
}
|
||||
}
|
||||
|
||||
// decodedProgressFresh ports 3169.
|
||||
func (c *Controller) decodedProgressFresh(childPeerID string, edge *CommittedEdge, nowMs int64) bool {
|
||||
observation := c.qualityObservations[childPeerID]
|
||||
current, _ := c.participants.Get(childPeerID)
|
||||
return observation != nil &&
|
||||
observation.lastDecodedProgressAtMs != nil &&
|
||||
nowMs < *observation.lastDecodedProgressAtMs+qualityEvidenceExpiryMs &&
|
||||
current != nil && current.sessionIs(observation.childSessionID) &&
|
||||
edge.ChildSessionID == observation.childSessionID &&
|
||||
edge.ConnectionID == observation.connectionID &&
|
||||
qualityObservationMatchesEdge(observation, edge) &&
|
||||
c.sourceUsableForQuality(edge)
|
||||
}
|
||||
|
||||
// buildQualityCandidates ports 3190: clear peers, remaining peers, SFU
|
||||
// (only under Host fanout relief), same-parent regeneration (§4.4 #36),
|
||||
// filtered by the per-observation consumed ledger.
|
||||
func (c *Controller) buildQualityCandidates(childPeerID string, nowMs int64) []CandidatePlan {
|
||||
current, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
if current == nil || current.Kind != UpstreamPeer {
|
||||
return []CandidatePlan{}
|
||||
}
|
||||
currentKey := edgeTupleKey(current)
|
||||
candidates := []CandidatePlan{}
|
||||
for _, candidate := range c.buildCandidatePlans(childPeerID, false, false) {
|
||||
if candidate.EndpointTransition.Kind == TransitionBoundedGap || tupleKey(candidate.Tuple) == currentKey {
|
||||
continue
|
||||
}
|
||||
if candidate.Tuple.Kind == UpstreamPeer ||
|
||||
candidate.Tuple.Publication != PublicationReuse ||
|
||||
(c.hostPublication != nil && c.sfuPublisherQualityState(c.hostPublication.Generation, nowMs) == SenderQualityHealthy) {
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
clearPeers := []CandidatePlan{}
|
||||
remainingPeers := []CandidatePlan{}
|
||||
sfu := []CandidatePlan{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Tuple.Kind != UpstreamPeer {
|
||||
sfu = append(sfu, candidate)
|
||||
continue
|
||||
}
|
||||
if c.qualitySourcePathHealthy(candidate.Tuple.ParentPeerID, nowMs) {
|
||||
clearPeers = append(clearPeers, candidate)
|
||||
} else {
|
||||
remainingPeers = append(remainingPeers, candidate)
|
||||
}
|
||||
}
|
||||
if !(current.ParentPeerID == c.hostPeerID && c.hostFanoutNeedsSfuRelief(nowMs)) {
|
||||
sfu = nil
|
||||
}
|
||||
regeneration := []CandidatePlan{}
|
||||
if !c.senderQualityRegenerationBlocked(childPeerID, current) {
|
||||
regenerationTuple := CandidateTuple{Kind: UpstreamPeer, ParentPeerID: current.ParentPeerID, Transport: TransportDirect, Regenerate: true}
|
||||
if plan := c.planCandidate(childPeerID, regenerationTuple); plan != nil &&
|
||||
plan.EndpointTransition.Kind != TransitionBoundedGap &&
|
||||
c.candidateValid(childPeerID, *plan, nil) {
|
||||
regeneration = append(regeneration, *plan)
|
||||
}
|
||||
}
|
||||
prioritized := make([]CandidatePlan, 0, len(candidates)+1)
|
||||
prioritized = append(prioritized, clearPeers...)
|
||||
prioritized = append(prioritized, remainingPeers...)
|
||||
prioritized = append(prioritized, sfu...)
|
||||
prioritized = append(prioritized, regeneration...)
|
||||
out := make([]CandidatePlan, 0, len(prioritized))
|
||||
for _, candidate := range prioritized {
|
||||
if c.qualityCandidateOpportunityAvailable(childPeerID, candidate) {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectQualityChild ports 3251.
|
||||
func (c *Controller) selectQualityChild(nowMs int64) string {
|
||||
if !c.qualityConvergenceEnabled {
|
||||
return ""
|
||||
}
|
||||
targets := []*participant{}
|
||||
for _, viewer := range c.availableViewers(false) {
|
||||
edge, _ := c.upstreamByViewer.Get(viewer.peerID)
|
||||
if edge != nil && edge.Kind == UpstreamPeer &&
|
||||
!c.senderQualityBaselines[viewer.peerID] && // `get(...) !== true`
|
||||
c.senderQualityPersistentlyDegraded(viewer.peerID, edge, nowMs) &&
|
||||
len(c.buildQualityCandidates(viewer.peerID, nowMs)) > 0 {
|
||||
targets = append(targets, viewer)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(targets, func(i, j int) bool {
|
||||
if d := c.depth(targets[i].peerID) - c.depth(targets[j].peerID); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
return compareParticipant(targets[i], targets[j]) < 0
|
||||
})
|
||||
if len(targets) == 0 {
|
||||
return ""
|
||||
}
|
||||
return targets[0].peerID
|
||||
}
|
||||
|
||||
// senderQualityPersistentlyDegraded ports 3387.
|
||||
func (c *Controller) senderQualityPersistentlyDegraded(childPeerID string, edge *CommittedEdge, nowMs int64) bool {
|
||||
observation := c.senderQualityObservations[childPeerID]
|
||||
return c.senderQualityState(childPeerID, edge, nowMs) == SenderQualityDegraded &&
|
||||
observation != nil &&
|
||||
observation.consecutiveDegradedWindows >= persistentDegradedWindows
|
||||
}
|
||||
|
||||
// qualityOperationStillEligible ports 3401.
|
||||
func (c *Controller) qualityOperationStillEligible(op *operation, nowMs int64) bool {
|
||||
currentEdge, _ := c.upstreamByViewer.Get(op.childPeerID)
|
||||
observation := c.currentQualityOperationObservation(op)
|
||||
tuple := op.currentTuple()
|
||||
if currentEdge == nil || currentEdge.Kind != UpstreamPeer || tuple == nil || observation == nil {
|
||||
return false
|
||||
}
|
||||
state := c.senderQualityState(op.childPeerID, currentEdge, nowMs)
|
||||
if state == SenderQualityHealthy {
|
||||
return false
|
||||
}
|
||||
return tuple.Kind == UpstreamPeer ||
|
||||
(state == SenderQualityDegraded && c.hostFanoutNeedsSfuRelief(nowMs))
|
||||
}
|
||||
|
||||
// hostFanoutNeedsSfuRelief ports 3431.
|
||||
func (c *Controller) hostFanoutNeedsSfuRelief(nowMs int64) bool {
|
||||
count := 0
|
||||
for childPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind != UpstreamPeer || edge.ParentPeerID != c.hostPeerID || !edge.Usable || !edge.PhysicalActive {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if !c.senderQualityPersistentlyDegraded(childPeerID, edge, nowMs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return count >= 2
|
||||
}
|
||||
|
||||
// qualityUpstreamMatches ports 4506.
|
||||
func qualityUpstreamMatches(edge *CommittedEdge, upstream QualityUpstream) bool {
|
||||
if edge.Kind == UpstreamPeer {
|
||||
return upstream.Kind == UpstreamPeer && upstream.PeerID == edge.ParentPeerID
|
||||
}
|
||||
return upstream.Kind == UpstreamSfu
|
||||
}
|
||||
|
||||
// qualityObservationMatchesEdge ports 4515; upstreamPeerID "" is TS null.
|
||||
func qualityObservationMatchesEdge(observation *routeQualityObservation, edge *CommittedEdge) bool {
|
||||
if edge.Kind == UpstreamPeer {
|
||||
return observation.upstreamKind == UpstreamPeer && observation.upstreamPeerID == edge.ParentPeerID
|
||||
}
|
||||
return observation.upstreamKind == UpstreamSfu && observation.upstreamPeerID == ""
|
||||
}
|
||||
|
||||
// sourceUsableForQuality ports 4526.
|
||||
func (c *Controller) sourceUsableForQuality(edge *CommittedEdge) bool {
|
||||
if edge.Kind == UpstreamPeer {
|
||||
parent, _ := c.participants.Get(edge.ParentPeerID)
|
||||
return parent != nil && parent.sessionIs(edge.ParentSessionID) && c.sourceUsable(edge.ParentPeerID)
|
||||
}
|
||||
return c.hostPublication != nil && c.hostPublication.Usable &&
|
||||
c.hostPublication.PhysicalActive &&
|
||||
c.hostPublication.Generation == edge.PublicationGeneration
|
||||
}
|
||||
|
||||
// clearQualityForParticipant ports 4541: the Host clears every child, a
|
||||
// viewer clears its subtree plus itself (§4.2 #21, §4.5 #50).
|
||||
func (c *Controller) clearQualityForParticipant(peerID string) {
|
||||
var affected []string
|
||||
if peerID == c.hostPeerID {
|
||||
affected = c.upstreamByViewer.Keys()
|
||||
} else {
|
||||
affected = withSelf(c.descendantsOf(peerID), peerID)
|
||||
}
|
||||
for _, childPeerID := range affected {
|
||||
delete(c.qualityObservations, childPeerID)
|
||||
delete(c.senderQualityObservations, childPeerID)
|
||||
delete(c.senderQualityBaselines, childPeerID)
|
||||
delete(c.senderQualityRegenerationBlocks, childPeerID)
|
||||
if c.upstreamByViewer.Has(childPeerID) {
|
||||
c.qualityBaselinesPending[childPeerID] = struct{}{}
|
||||
c.senderQualityBaselines[childPeerID] = true
|
||||
} else {
|
||||
delete(c.qualityBaselinesPending, childPeerID)
|
||||
delete(c.senderQualityBaselines, childPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireSenderQualityBaseline ports 4609; a missing baseline reads as
|
||||
// false (`get(...) === true`).
|
||||
func (c *Controller) requireSenderQualityBaseline(peerID string, suppressFact bool) {
|
||||
for _, childPeerID := range withSelf(c.descendantsOf(peerID), peerID) {
|
||||
if c.upstreamByViewer.Has(childPeerID) {
|
||||
delete(c.senderQualityObservations, childPeerID)
|
||||
c.senderQualityBaselines[childPeerID] = suppressFact || c.senderQualityBaselines[childPeerID]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearSfuQuality ports 4624.
|
||||
func (c *Controller) clearSfuQuality() {
|
||||
c.sfuPublisherQualityObservation = nil
|
||||
for childPeerID, edge := range c.upstreamByViewer.All() {
|
||||
if edge.Kind == UpstreamSfu {
|
||||
c.clearQualityForParticipant(childPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package route
|
||||
|
||||
import "sort"
|
||||
|
||||
// rootConvergencePick is the takeRootConvergenceIntent result.
|
||||
type rootConvergencePick struct {
|
||||
childPeerID string
|
||||
plan CandidatePlan
|
||||
}
|
||||
|
||||
// stageRootConvergence ports 3272: staged from commitAttempt whenever a
|
||||
// child newly became a direct child of the Host.
|
||||
func (c *Controller) stageRootConvergence(rootPeerID string) {
|
||||
if !c.qualityConvergenceEnabled {
|
||||
return
|
||||
}
|
||||
convergence := c.rootConvergencePlan(rootPeerID)
|
||||
if convergence == nil {
|
||||
return
|
||||
}
|
||||
c.rootConvergenceRootPeerID = rootPeerID
|
||||
c.debug("root-convergence-staged",
|
||||
"root", c.debugPeer(rootPeerID),
|
||||
"donor", c.debugPeer(convergence.donorPeerID),
|
||||
"child", c.debugPeer(convergence.childPeerID))
|
||||
}
|
||||
|
||||
// takeRootConvergenceIntent ports 3284: one-shot (take = clear).
|
||||
func (c *Controller) takeRootConvergenceIntent() *rootConvergencePick {
|
||||
rootPeerID := c.rootConvergenceRootPeerID
|
||||
c.rootConvergenceRootPeerID = ""
|
||||
if rootPeerID == "" || !c.qualityConvergenceEnabled {
|
||||
return nil
|
||||
}
|
||||
convergence := c.rootConvergencePlan(rootPeerID)
|
||||
if convergence == nil {
|
||||
return nil
|
||||
}
|
||||
return &rootConvergencePick{childPeerID: convergence.childPeerID, plan: convergence.plan}
|
||||
}
|
||||
|
||||
type rootConvergence struct {
|
||||
donorPeerID string
|
||||
childPeerID string
|
||||
plan CandidatePlan
|
||||
}
|
||||
|
||||
// rootConvergencePlan ports 3299: the busiest other Host root donates its
|
||||
// newest active direct child to the fresh root (§4.5 #45).
|
||||
func (c *Controller) rootConvergencePlan(rootPeerID string) *rootConvergence {
|
||||
root, _ := c.participants.Get(rootPeerID)
|
||||
if root == nil || root.sessionID == "" ||
|
||||
root.departureConfirmed ||
|
||||
!c.isActiveHostRoot(rootPeerID) ||
|
||||
len(c.activeDirectChildren(rootPeerID)) != 0 {
|
||||
return nil
|
||||
}
|
||||
donors := []string{}
|
||||
for _, peerID := range c.childrenOf(c.hostPeerID) {
|
||||
if peerID != rootPeerID && c.isActiveHostRoot(peerID) && len(c.activeDirectChildren(peerID)) >= 2 {
|
||||
donors = append(donors, peerID)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(donors, func(i, j int) bool {
|
||||
if d := len(c.activeDirectChildren(donors[j])) - len(c.activeDirectChildren(donors[i])); d != 0 {
|
||||
return d < 0
|
||||
}
|
||||
left, _ := c.participants.Get(donors[i])
|
||||
right, _ := c.participants.Get(donors[j])
|
||||
return compareParticipant(left, right) < 0
|
||||
})
|
||||
if len(donors) == 0 {
|
||||
return nil
|
||||
}
|
||||
donorPeerID := donors[0]
|
||||
children := c.activeDirectChildren(donorPeerID)
|
||||
sort.SliceStable(children, func(i, j int) bool {
|
||||
left, _ := c.participants.Get(children[i])
|
||||
right, _ := c.participants.Get(children[j])
|
||||
return compareParticipant(right, left) < 0
|
||||
})
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
childPeerID := children[0]
|
||||
plan := c.planCandidate(childPeerID, CandidateTuple{Kind: UpstreamPeer, ParentPeerID: rootPeerID, Transport: TransportDirect})
|
||||
if plan != nil &&
|
||||
plan.EndpointTransition.Kind != TransitionBoundedGap &&
|
||||
c.candidateValid(childPeerID, *plan, nil) {
|
||||
return &rootConvergence{donorPeerID: donorPeerID, childPeerID: childPeerID, plan: *plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rootConvergenceOperationStillEligible ports 3350.
|
||||
func (c *Controller) rootConvergenceOperationStillEligible(op *operation) bool {
|
||||
if op.reason != DemandRootConvergence {
|
||||
return true
|
||||
}
|
||||
tuple := op.currentTuple()
|
||||
current, _ := c.upstreamByViewer.Get(op.childPeerID)
|
||||
return tuple != nil && tuple.Kind == UpstreamPeer &&
|
||||
current != nil && current.Kind == UpstreamPeer &&
|
||||
current.Usable &&
|
||||
current.PhysicalActive &&
|
||||
current.ParentPeerID != tuple.ParentPeerID &&
|
||||
c.isActiveHostRoot(tuple.ParentPeerID) &&
|
||||
c.isActiveHostRoot(current.ParentPeerID) &&
|
||||
len(c.activeDirectChildren(tuple.ParentPeerID)) == 0 &&
|
||||
len(c.activeDirectChildren(current.ParentPeerID)) >= 2
|
||||
}
|
||||
|
||||
// isActiveHostRoot ports 3370.
|
||||
func (c *Controller) isActiveHostRoot(peerID string) bool {
|
||||
edge, _ := c.upstreamByViewer.Get(peerID)
|
||||
return edge != nil && edge.Kind == UpstreamPeer &&
|
||||
edge.ParentPeerID == c.hostPeerID &&
|
||||
edge.Usable &&
|
||||
edge.PhysicalActive
|
||||
}
|
||||
|
||||
// activeDirectChildren ports 3380.
|
||||
func (c *Controller) activeDirectChildren(parentPeerID string) []string {
|
||||
children := []string{}
|
||||
for _, childPeerID := range c.childrenOf(parentPeerID) {
|
||||
if edge, ok := c.upstreamByViewer.Get(childPeerID); ok && edge.Kind == UpstreamPeer && edge.Usable && edge.PhysicalActive {
|
||||
children = append(children, childPeerID)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// peekSfuBootstrapCarrier ports 3597.
|
||||
func (c *Controller) peekSfuBootstrapCarrier() *sfuBootstrapCarrier {
|
||||
intent := c.currentSfuBootstrapIntent()
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
carriers := c.safeSfuBootstrapCarriers(intent.demandPeerID)
|
||||
if len(carriers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &sfuBootstrapCarrier{
|
||||
demandPeerID: intent.demandPeerID,
|
||||
demandSessionID: intent.demandSessionID,
|
||||
carrierPeerID: carriers[0],
|
||||
}
|
||||
}
|
||||
|
||||
// hasSfuBootstrapWork ports 3612.
|
||||
func (c *Controller) hasSfuBootstrapWork() bool {
|
||||
if c.currentSfuBootstrapIntent() != nil {
|
||||
return true
|
||||
}
|
||||
for _, viewer := range c.availableViewers(true) {
|
||||
if viewer.availabilityExhausted &&
|
||||
c.sfuBootstrapNeeded(viewer.peerID) &&
|
||||
(len(c.safeSfuBootstrapCarriers(viewer.peerID)) > 0 || !viewer.bootstrapFailureReported) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// takeSfuBootstrapCarrier ports 3623.
|
||||
func (c *Controller) takeSfuBootstrapCarrier(nowMs int64, failedPeerIDs *[]string) *sfuBootstrapCarrier {
|
||||
if !c.sfuBootstrapGloballyNeeded() {
|
||||
return nil
|
||||
}
|
||||
if carrier := c.peekSfuBootstrapCarrier(); carrier != nil {
|
||||
return carrier
|
||||
}
|
||||
c.sfuBootstrapIntent = nil
|
||||
c.reportSfuBootstrapFailures(nowMs, failedPeerIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentSfuBootstrapIntent ports 3635: the sticky intent is revalidated on
|
||||
// every access and otherwise re-derived from the first exhausted viewer in
|
||||
// availableViewers order (§4.5 #43).
|
||||
func (c *Controller) currentSfuBootstrapIntent() *sfuBootstrapIntent {
|
||||
if current := c.sfuBootstrapIntent; current != nil {
|
||||
demand, _ := c.participants.Get(current.demandPeerID)
|
||||
if c.sfuBootstrapNeeded(current.demandPeerID) &&
|
||||
demand != nil && demand.sessionIs(current.demandSessionID) &&
|
||||
len(c.safeSfuBootstrapCarriers(current.demandPeerID)) > 0 {
|
||||
return current
|
||||
}
|
||||
c.sfuBootstrapIntent = nil
|
||||
}
|
||||
for _, viewer := range c.availableViewers(true) {
|
||||
if viewer.availabilityExhausted &&
|
||||
c.sfuBootstrapNeeded(viewer.peerID) &&
|
||||
len(c.safeSfuBootstrapCarriers(viewer.peerID)) > 0 {
|
||||
if viewer.sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
created := &sfuBootstrapIntent{demandPeerID: viewer.peerID, demandSessionID: viewer.sessionID}
|
||||
c.sfuBootstrapIntent = created
|
||||
return created
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootstrapOperationOwned ports 3663; checked first in validateOrAdvance
|
||||
// so a bootstrap operation evaporates with its intent.
|
||||
func (c *Controller) bootstrapOperationOwned(op *operation) bool {
|
||||
if op.reason != DemandSfuBootstrap {
|
||||
return true
|
||||
}
|
||||
intent := c.sfuBootstrapIntent
|
||||
if intent == nil ||
|
||||
intent.demandPeerID != op.demandPeerID ||
|
||||
intent.demandSessionID != op.demandSessionID ||
|
||||
!c.sfuBootstrapNeeded(op.demandPeerID) {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(c.safeSfuBootstrapCarriers(op.demandPeerID), op.childPeerID)
|
||||
}
|
||||
|
||||
// sfuBootstrapNeeded ports 3681.
|
||||
func (c *Controller) sfuBootstrapNeeded(demandPeerID string) bool {
|
||||
if !c.sfuBootstrapGloballyNeeded() {
|
||||
return false
|
||||
}
|
||||
demand, _ := c.participants.Get(demandPeerID)
|
||||
return demand != nil && demand.sessionID != "" &&
|
||||
!c.usableRoute(demandPeerID) &&
|
||||
c.sfuOpportunityAvailable(demandPeerID)
|
||||
}
|
||||
|
||||
// sfuOpportunityAvailable ports 3690.
|
||||
func (c *Controller) sfuOpportunityAvailable(demandPeerID string) bool {
|
||||
return c.candidateOpportunityAvailable(demandPeerID, CandidatePlan{
|
||||
Tuple: CandidateTuple{Kind: UpstreamSfu, Publication: PublicationReuse},
|
||||
EndpointTransition: EndpointTransition{Kind: TransitionNone},
|
||||
})
|
||||
}
|
||||
|
||||
// sfuBootstrapGloballyNeeded ports 3697.
|
||||
func (c *Controller) sfuBootstrapGloballyNeeded() bool {
|
||||
return c.sfuEnabled && c.hostPublication == nil && !c.hostHasPublicationSlot()
|
||||
}
|
||||
|
||||
// safeSfuBootstrapCarriers ports 3705.
|
||||
func (c *Controller) safeSfuBootstrapCarriers(demandPeerID string) []string {
|
||||
if !c.sfuBootstrapNeeded(demandPeerID) {
|
||||
return []string{}
|
||||
}
|
||||
return c.sfuBootstrapCarriers()
|
||||
}
|
||||
|
||||
// sfuBootstrapCarriers ports 3712: the Host's usable direct children that
|
||||
// can create a publication, newest first (§4.5 #44).
|
||||
func (c *Controller) sfuBootstrapCarriers() []string {
|
||||
tuple := CandidateTuple{Kind: UpstreamSfu, Publication: PublicationCreate}
|
||||
carriers := []string{}
|
||||
for _, peerID := range c.childrenOf(c.hostPeerID) {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
edge, _ := c.upstreamByViewer.Get(peerID)
|
||||
plan := c.planCandidate(peerID, tuple)
|
||||
if current != nil && current.sessionID != "" &&
|
||||
edge != nil && edge.Kind == UpstreamPeer &&
|
||||
edge.Transport == TransportDirect &&
|
||||
edge.Usable &&
|
||||
edge.PhysicalActive &&
|
||||
plan != nil &&
|
||||
plan.EndpointTransition.Kind != TransitionBoundedGap &&
|
||||
c.sfuBootstrapOpportunityAvailable(peerID, *plan) {
|
||||
carriers = append(carriers, peerID)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(carriers, func(i, j int) bool {
|
||||
left, _ := c.participants.Get(carriers[i])
|
||||
right, _ := c.participants.Get(carriers[j])
|
||||
return compareParticipant(right, left) < 0
|
||||
})
|
||||
return carriers
|
||||
}
|
||||
|
||||
// sfuBootstrapOpportunityAvailable ports 3744.
|
||||
func (c *Controller) sfuBootstrapOpportunityAvailable(carrierPeerID string, plan CandidatePlan) bool {
|
||||
consumed, ok := c.consumedSfuBootstrapOpportunities.Get(c.sfuBootstrapOpportunityBase(carrierPeerID, plan))
|
||||
return !ok || endpointTransitionRank(plan.EndpointTransition) < consumed
|
||||
}
|
||||
|
||||
// sfuBootstrapOpportunityBase ports 3757.
|
||||
func (c *Controller) sfuBootstrapOpportunityBase(carrierPeerID string, plan CandidatePlan) string {
|
||||
return carrierPeerID + "\x00" + c.candidateOpportunityBase(carrierPeerID, plan)
|
||||
}
|
||||
|
||||
// consumeSfuBootstrapOpportunity ports 3767.
|
||||
func (c *Controller) consumeSfuBootstrapOpportunity(carrierPeerID string, plan CandidatePlan) {
|
||||
consumeOpportunity(&c.consumedSfuBootstrapOpportunities,
|
||||
c.sfuBootstrapOpportunityBase(carrierPeerID, plan),
|
||||
endpointTransitionRank(plan.EndpointTransition))
|
||||
}
|
||||
|
||||
// completeSfuBootstrapCarrier ports 3778: consumes the carrier's attempt
|
||||
// and returns the failed demand peer only once no carrier is left.
|
||||
func (c *Controller) completeSfuBootstrapCarrier(carrierPeerID string, nowMs int64, bucket RejectionBucket, attemptedPlan *CandidatePlan) string {
|
||||
intent := c.sfuBootstrapIntent
|
||||
if intent == nil {
|
||||
return ""
|
||||
}
|
||||
plan := attemptedPlan
|
||||
if plan == nil {
|
||||
plan = c.planCandidate(carrierPeerID, CandidateTuple{Kind: UpstreamSfu, Publication: PublicationCreate})
|
||||
}
|
||||
if plan != nil {
|
||||
c.consumeSfuBootstrapOpportunity(carrierPeerID, *plan)
|
||||
c.consumeCandidateOpportunity(carrierPeerID, *plan)
|
||||
}
|
||||
if len(c.safeSfuBootstrapCarriers(intent.demandPeerID)) > 0 {
|
||||
return ""
|
||||
}
|
||||
demand, _ := c.participants.Get(intent.demandPeerID)
|
||||
if demand != nil {
|
||||
demand.availabilityExhausted = true
|
||||
}
|
||||
c.finishTiming(intent.demandPeerID, nowMs, FinalRouteFailed, bucket, false)
|
||||
if demand != nil {
|
||||
demand.bootstrapFailureReported = true
|
||||
}
|
||||
c.sfuBootstrapIntent = nil
|
||||
return intent.demandPeerID
|
||||
}
|
||||
|
||||
// reportSfuBootstrapFailures ports 3808 (availableViewers order, §4.5 #43).
|
||||
func (c *Controller) reportSfuBootstrapFailures(nowMs int64, failedPeerIDs *[]string) {
|
||||
for _, demand := range c.availableViewers(true) {
|
||||
if !demand.availabilityExhausted ||
|
||||
!c.sfuBootstrapNeeded(demand.peerID) ||
|
||||
c.usableRoute(demand.peerID) ||
|
||||
demand.bootstrapFailureReported {
|
||||
continue
|
||||
}
|
||||
demand.bootstrapFailureReported = true
|
||||
c.finishTiming(demand.peerID, nowMs, FinalRouteFailed, RejectionCandidateFailed, false)
|
||||
*failedPeerIDs = append(*failedPeerIDs, demand.peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// rememberUnavailableSfuBootstrap ports 3827.
|
||||
func (c *Controller) rememberUnavailableSfuBootstrap(demandPeerID string) {
|
||||
if !c.sfuBootstrapNeeded(demandPeerID) {
|
||||
return
|
||||
}
|
||||
demand, _ := c.participants.Get(demandPeerID)
|
||||
if demand == nil || demand.sessionID == "" {
|
||||
return
|
||||
}
|
||||
demand.bootstrapFailureReported = true
|
||||
c.sfuBootstrapIntent = nil
|
||||
}
|
||||
|
||||
// stageSfuBootstrap ports 3835.
|
||||
func (c *Controller) stageSfuBootstrap(op *operation) {
|
||||
demand, _ := c.participants.Get(op.demandPeerID)
|
||||
if demand == nil || demand.sessionID == "" || !c.sfuBootstrapNeeded(demand.peerID) {
|
||||
return
|
||||
}
|
||||
if len(c.safeSfuBootstrapCarriers(demand.peerID)) == 0 {
|
||||
return
|
||||
}
|
||||
if current := c.sfuBootstrapIntent; current != nil &&
|
||||
current.demandPeerID == demand.peerID &&
|
||||
current.demandSessionID == demand.sessionID {
|
||||
return
|
||||
}
|
||||
c.sfuBootstrapIntent = &sfuBootstrapIntent{demandPeerID: demand.peerID, demandSessionID: demand.sessionID}
|
||||
}
|
||||
|
||||
// clearSfuBootstrapForDemand ports 3854.
|
||||
func (c *Controller) clearSfuBootstrapForDemand(demandPeerID string) {
|
||||
if demand, ok := c.participants.Get(demandPeerID); ok {
|
||||
demand.sfuFirstAtNextRoute = false
|
||||
}
|
||||
if c.sfuBootstrapIntent != nil && c.sfuBootstrapIntent.demandPeerID == demandPeerID {
|
||||
c.sfuBootstrapIntent = nil
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrapCandidateAvailable ports 4334.
|
||||
func (c *Controller) bootstrapCandidateAvailable(demandPeerID string) bool {
|
||||
return len(c.safeSfuBootstrapCarriers(demandPeerID)) > 0
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// TestSmokeCommitsOneDirectCandidate wires the controller end to end: a
|
||||
// Host and two Viewers join, reconcile starts one join operation for the
|
||||
// first Viewer with the Host as its first direct candidate, and begin +
|
||||
// candidateReady commit that edge at the attempt's revision.
|
||||
func TestSmokeCommitsOneDirectCandidate(t *testing.T) {
|
||||
const host = "smoke_host_1234"
|
||||
const first = "smoke_viewer_a_1"
|
||||
const second = "smoke_viewer_b_1"
|
||||
routes := New(Options{HostPeerID: host, EndpointMediaCopyCapacity: 2, OperationTimeoutMs: 10_000})
|
||||
routes.UpsertParticipant(ParticipantInput{PeerID: host, Role: protocol.RoleHost, SessionID: "host_s", EffectiveDownstreamCapacity: 2}, nil)
|
||||
now := int64(1_000)
|
||||
for _, viewer := range []string{first, second} {
|
||||
routes.UpsertParticipant(ParticipantInput{PeerID: viewer, Role: protocol.RoleViewer, SessionID: viewer + "_s", EffectiveDownstreamCapacity: 2}, &now)
|
||||
}
|
||||
|
||||
result := routes.Reconcile(now)
|
||||
if result.Operation == nil {
|
||||
t.Fatal("expected reconcile to start an operation")
|
||||
}
|
||||
operation := result.Operation
|
||||
if operation.ChildPeerID != first || operation.Reason != DemandJoin {
|
||||
t.Fatalf("operation = %+v, want a join for the first viewer", operation)
|
||||
}
|
||||
if len(operation.Candidates) == 0 || operation.Candidates[0].Tuple != (CandidateTuple{Kind: UpstreamPeer, ParentPeerID: host, Transport: TransportDirect}) {
|
||||
t.Fatalf("candidates = %+v, want the Host first", operation.Candidates)
|
||||
}
|
||||
if operation.WakeAtMs != operation.DeadlineAtMs || operation.DeadlineAtMs != now+10_000 {
|
||||
t.Fatalf("deadline %d wake %d, want both at %d", operation.DeadlineAtMs, operation.WakeAtMs, now+10_000)
|
||||
}
|
||||
|
||||
begin := routes.BeginCurrentCandidate(BeginInput{
|
||||
Guard: CandidateCursorGuard{
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ChildSessionID: operation.ChildSessionID,
|
||||
BaseRevision: operation.BaseRevision,
|
||||
FactVersion: operation.FactVersion,
|
||||
Cursor: operation.Cursor,
|
||||
Plan: operation.Candidates[operation.Cursor],
|
||||
},
|
||||
NowMs: now + 1,
|
||||
ConnectionID: "conn_a",
|
||||
Reservation: CandidateReservation{Kind: ReservationDirect},
|
||||
})
|
||||
if !begin.Accepted || begin.Operation == nil || begin.Operation.Current == nil {
|
||||
t.Fatalf("begin = %+v, want an accepted live attempt", begin)
|
||||
}
|
||||
if routes.Snapshot().Revision != operation.BaseRevision {
|
||||
t.Fatal("beginCurrentCandidate must not advance the advertised revision")
|
||||
}
|
||||
|
||||
settled := routes.CandidateReady(CandidateGuard{
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ChildSessionID: operation.ChildSessionID,
|
||||
Revision: begin.Operation.Current.Revision,
|
||||
ConnectionID: "conn_a",
|
||||
}, now+2, nil, CandidateProof{})
|
||||
if !settled.Accepted || !settled.Committed || len(settled.Released) != 0 {
|
||||
t.Fatalf("candidateReady = %+v, want a clean commit", settled)
|
||||
}
|
||||
snapshot := routes.Snapshot()
|
||||
if snapshot.Revision != begin.Operation.Current.Revision || snapshot.Operation != nil {
|
||||
t.Fatalf("snapshot revision %d operation %v, want %d and idle", snapshot.Revision, snapshot.Operation, begin.Operation.Current.Revision)
|
||||
}
|
||||
edge, ok := snapshot.UpstreamByViewer.Get(first)
|
||||
if !ok || edge.Kind != UpstreamPeer || edge.ParentPeerID != host || edge.ConnectionID != "conn_a" || !edge.Usable || !edge.PhysicalActive {
|
||||
t.Fatalf("edge = %+v, want a usable direct edge from the Host", edge)
|
||||
}
|
||||
if next := routes.Reconcile(now + 3).Operation; next == nil || next.ChildPeerID != second {
|
||||
t.Fatalf("next operation = %+v, want the second viewer", next)
|
||||
}
|
||||
diagnostic := routes.RouteDiagnosticSnapshot(now + 3)
|
||||
if len(diagnostic.Children) != 2 || diagnostic.Children[0].Parent.Kind != "host" || diagnostic.Children[0].FinalRoute != "direct" {
|
||||
t.Fatalf("diagnostic = %+v, want the first child routed through the Host", diagnostic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package route
|
||||
|
||||
import "github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
|
||||
// recordDemand ports 4633: replaces the peer's timing record wholesale.
|
||||
func (c *Controller) recordDemand(childPeerID string, nowMs int64, reason DemandReason) {
|
||||
c.routeTimings[childPeerID] = &routeTimingRecord{
|
||||
demandAtMs: nowMs,
|
||||
reason: reason,
|
||||
finalRoute: FinalRouteWaiting,
|
||||
rejectionBucket: RejectionNone,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDemand ports 4647.
|
||||
func (c *Controller) ensureDemand(childPeerID string, nowMs int64, reason DemandReason) {
|
||||
if _, ok := c.routeTimings[childPeerID]; !ok {
|
||||
c.recordDemand(childPeerID, nowMs, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// startOperationTiming ports 4657.
|
||||
func (c *Controller) startOperationTiming(childPeerID string, nowMs int64) {
|
||||
record := c.routeTimings[childPeerID]
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
startedAt := nowMs
|
||||
record.operationStartedAtMs = &startedAt
|
||||
record.candidateStartedAtMs = nil
|
||||
record.firstDecodedFrameAtMs = nil
|
||||
record.finalAtMs = nil
|
||||
record.finalRoute = FinalRouteWaiting
|
||||
record.rejectionBucket = RejectionNone
|
||||
}
|
||||
|
||||
// startCandidateTiming ports 4668.
|
||||
func (c *Controller) startCandidateTiming(childPeerID string, nowMs int64) {
|
||||
record := c.routeTimings[childPeerID]
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
startedAt := nowMs
|
||||
record.candidateStartedAtMs = &startedAt
|
||||
record.firstDecodedFrameAtMs = nil
|
||||
record.finalAtMs = nil
|
||||
record.finalRoute = FinalRouteWaiting
|
||||
record.rejectionBucket = RejectionNone
|
||||
}
|
||||
|
||||
// noteRejection ports 4678.
|
||||
func (c *Controller) noteRejection(childPeerID string, bucket RejectionBucket) {
|
||||
if record := c.routeTimings[childPeerID]; record != nil {
|
||||
record.rejectionBucket = bucket
|
||||
}
|
||||
}
|
||||
|
||||
// clearCandidateTiming ports 4686.
|
||||
func (c *Controller) clearCandidateTiming(childPeerID string) {
|
||||
record := c.routeTimings[childPeerID]
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
record.candidateStartedAtMs = nil
|
||||
record.firstDecodedFrameAtMs = nil
|
||||
record.finalAtMs = nil
|
||||
record.finalRoute = FinalRouteWaiting
|
||||
}
|
||||
|
||||
// finishTiming ports 4695.
|
||||
func (c *Controller) finishTiming(childPeerID string, nowMs int64, finalRoute FinalRoute, rejectionBucket RejectionBucket, firstDecodedFrame bool) {
|
||||
record := c.routeTimings[childPeerID]
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
if firstDecodedFrame {
|
||||
decodedAt := nowMs
|
||||
record.firstDecodedFrameAtMs = &decodedAt
|
||||
}
|
||||
finalAt := nowMs
|
||||
record.finalAtMs = &finalAt
|
||||
record.finalRoute = finalRoute
|
||||
record.rejectionBucket = rejectionBucket
|
||||
}
|
||||
|
||||
// usableRoute ports 4710.
|
||||
func (c *Controller) usableRoute(childPeerID string) bool {
|
||||
return c.currentFinalRoute(childPeerID) != FinalRouteWaiting
|
||||
}
|
||||
|
||||
// currentFinalRoute ports 4714.
|
||||
func (c *Controller) currentFinalRoute(childPeerID string) FinalRoute {
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
if edge == nil || !edge.Usable || !edge.PhysicalActive {
|
||||
return FinalRouteWaiting
|
||||
}
|
||||
if edge.Kind == UpstreamPeer {
|
||||
if c.sourceUsable(edge.ParentPeerID) {
|
||||
return FinalRouteDirect
|
||||
}
|
||||
return FinalRouteWaiting
|
||||
}
|
||||
if c.hostPublication != nil && c.hostPublication.Usable &&
|
||||
c.hostPublication.PhysicalActive &&
|
||||
c.hostPublication.Generation == edge.PublicationGeneration {
|
||||
return FinalRouteSfu
|
||||
}
|
||||
return FinalRouteWaiting
|
||||
}
|
||||
|
||||
// routeDemandReason ports 4729.
|
||||
func (c *Controller) routeDemandReason(childPeerID string) DemandReason {
|
||||
edge, _ := c.upstreamByViewer.Get(childPeerID)
|
||||
if edge == nil {
|
||||
return DemandJoin
|
||||
}
|
||||
if edge.Kind == UpstreamPeer {
|
||||
if parent, ok := c.participants.Get(edge.ParentPeerID); ok && parent.departureConfirmed {
|
||||
return DemandParentDeparted
|
||||
}
|
||||
for _, overflowing := range c.overflowPeerChildren(edge.ParentPeerID) {
|
||||
if overflowing == childPeerID {
|
||||
return DemandCapacityReduction
|
||||
}
|
||||
}
|
||||
}
|
||||
return DemandEdgeUnavailable
|
||||
}
|
||||
|
||||
// diagnosticParent ports 4448.
|
||||
func (c *Controller) diagnosticParent(edge *CommittedEdge, ordinals map[string]int) protocol.RouteDiagnosticParent {
|
||||
if edge == nil || !edge.Usable || !edge.PhysicalActive {
|
||||
return protocol.RouteDiagnosticParent{Kind: "none"}
|
||||
}
|
||||
if edge.Kind == UpstreamSfu {
|
||||
if c.hostPublication != nil && c.hostPublication.Usable &&
|
||||
c.hostPublication.PhysicalActive &&
|
||||
c.hostPublication.Generation == edge.PublicationGeneration {
|
||||
return protocol.RouteDiagnosticParent{Kind: "sfu"}
|
||||
}
|
||||
return protocol.RouteDiagnosticParent{Kind: "none"}
|
||||
}
|
||||
if edge.ParentPeerID == c.hostPeerID {
|
||||
return protocol.RouteDiagnosticParent{Kind: "host"}
|
||||
}
|
||||
ordinal, ok := ordinals[edge.ParentPeerID]
|
||||
if !ok {
|
||||
return protocol.RouteDiagnosticParent{Kind: "none"}
|
||||
}
|
||||
return protocol.RouteDiagnosticParent{Kind: "viewer", Ordinal: protocol.Int(ordinal)}
|
||||
}
|
||||
|
||||
// diagnosticQuality ports 4471: nil (TS null) unless the observation is
|
||||
// fresh, non-empty and still describes the current edge.
|
||||
func (c *Controller) diagnosticQuality(childPeerID string, edge *CommittedEdge, nowMs int64) *protocol.RouteDiagnosticQuality {
|
||||
observation := c.qualityObservations[childPeerID]
|
||||
current, _ := c.participants.Get(childPeerID)
|
||||
if c.paused ||
|
||||
observation == nil ||
|
||||
observation.eligibleWindows == 0 ||
|
||||
nowMs >= observation.lastAcceptedAtMs+qualityEvidenceExpiryMs ||
|
||||
current == nil || current.sessionID == "" ||
|
||||
!current.sessionIs(observation.childSessionID) ||
|
||||
edge == nil ||
|
||||
!edge.Usable ||
|
||||
!edge.PhysicalActive ||
|
||||
edge.ConnectionID != observation.connectionID ||
|
||||
!qualityObservationMatchesEdge(observation, edge) ||
|
||||
!c.sourceUsableForQuality(edge) {
|
||||
return nil
|
||||
}
|
||||
return &protocol.RouteDiagnosticQuality{
|
||||
EligibleWindows: protocol.Int(observation.eligibleWindows),
|
||||
EligibleDurationMs: protocol.Int(observation.eligibleDurationMs),
|
||||
FreezeWindows: protocol.Int(observation.freezeWindows),
|
||||
FreezeCount: protocol.Int(observation.freezeCount),
|
||||
FreezeDurationMs: protocol.Int(observation.freezeDurationMs),
|
||||
PauseCount: protocol.Int(observation.pauseCount),
|
||||
PauseDurationMs: protocol.Int(observation.pauseDurationMs),
|
||||
}
|
||||
}
|
||||
|
||||
// elapsedMs ports 4964: nil (TS null) when the end precedes the start or
|
||||
// the difference is not a safe integer; the inputs are integral so
|
||||
// Math.floor is the identity.
|
||||
func elapsedMs(startedAtMs, endedAtMs int64) *protocol.Int {
|
||||
elapsed := endedAtMs - startedAtMs
|
||||
if !isSafeInteger(elapsed) || elapsed < 0 {
|
||||
return nil
|
||||
}
|
||||
value := protocol.Int(elapsed)
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Package sfu ports the server's LiveKit-facing modules: the resource
|
||||
// admission ledger (src/server/sfu-resource-admission.ts), the RoomService
|
||||
// control plane (src/server/sfu-room-control.ts) and the participant token
|
||||
// issuer (src/server/livekit-token.ts).
|
||||
//
|
||||
// LiveKit access is stdlib only (DECISIONS D1): one HS256 JWT signed with
|
||||
// crypto/hmac and Twirp JSON over net/http, no SDK.
|
||||
package sfu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// ResourceFence is SfuResourceFence. It is a comparable value, so the
|
||||
// TypeScript cloneFence/sameFence helpers are a plain copy and `==`.
|
||||
type ResourceFence struct {
|
||||
RoomID string
|
||||
ShareGeneration string
|
||||
PublicationGeneration string
|
||||
}
|
||||
|
||||
// SubscriptionFence is SfuSubscriptionFence, which extends SfuResourceFence.
|
||||
// The embedded value is the `{ ...fence, viewerPeerId }` spread the router
|
||||
// uses to derive one from a publication fence.
|
||||
type SubscriptionFence struct {
|
||||
ResourceFence
|
||||
ViewerPeerID string
|
||||
}
|
||||
|
||||
// Usage is SfuResourceUsage.
|
||||
type Usage struct {
|
||||
Ingress int
|
||||
Egress int
|
||||
}
|
||||
|
||||
// AdmissionOptions is SfuResourceAdmissionOptions.
|
||||
type AdmissionOptions struct {
|
||||
IngressCapacity int
|
||||
EgressCapacity int
|
||||
}
|
||||
|
||||
// resourceState is the TypeScript ResourceState union.
|
||||
type resourceState string
|
||||
|
||||
const (
|
||||
stateReserved resourceState = "reserved"
|
||||
stateCommitted resourceState = "committed"
|
||||
stateDraining resourceState = "draining"
|
||||
)
|
||||
|
||||
type subscriptionEntry struct {
|
||||
fence SubscriptionFence
|
||||
state resourceState
|
||||
}
|
||||
|
||||
type publicationEntry struct {
|
||||
fence ResourceFence
|
||||
state resourceState
|
||||
// Ordering site: subscriptions are only counted, looked up by viewer and
|
||||
// state-scanned, so their iteration order is never observable. A plain map
|
||||
// is enough here; the two maps above it are ordered.
|
||||
subscriptions map[string]*subscriptionEntry
|
||||
}
|
||||
|
||||
// roomPublications is RoomResources.publications. Ordering site: commitPublication
|
||||
// returns the drained generations and beginDrainRoom returns every generation in
|
||||
// insertion order, both of which the router turns into drain tasks.
|
||||
type roomPublications = ordered.Map[string, *publicationEntry]
|
||||
|
||||
// Admission is SfuResourceAdmission: the ingress/egress ledger for the LiveKit
|
||||
// fallback. It is not safe for concurrent use; the signaling server calls it
|
||||
// under its global mutex, exactly as Node's single thread did.
|
||||
type Admission struct {
|
||||
// Ordering site: beginDrainAll walks the rooms in insertion order.
|
||||
rooms ordered.Map[string, *roomPublications]
|
||||
ingressCapacity int
|
||||
egressCapacity int
|
||||
ingressInUse int
|
||||
egressInUse int
|
||||
}
|
||||
|
||||
// NewAdmission mirrors the SfuResourceAdmission constructor. An invalid
|
||||
// capacity panics where the TypeScript throws: the capacities come from a
|
||||
// constant and from validated configuration, so this is an assertion.
|
||||
func NewAdmission(options AdmissionOptions) *Admission {
|
||||
assertPositiveSafeInteger(options.IngressCapacity, "SFU ingress capacity")
|
||||
assertPositiveSafeInteger(options.EgressCapacity, "SFU egress capacity")
|
||||
return &Admission{
|
||||
ingressCapacity: options.IngressCapacity,
|
||||
egressCapacity: options.EgressCapacity,
|
||||
}
|
||||
}
|
||||
|
||||
// ReservePublication ports reservePublication.
|
||||
func (admission *Admission) ReservePublication(fence ResourceFence) bool {
|
||||
assertFence(fence)
|
||||
room, _ := admission.rooms.Get(fence.RoomID)
|
||||
var existing *publicationEntry
|
||||
if room != nil {
|
||||
existing, _ = room.Get(publicationKey(fence))
|
||||
}
|
||||
if existing != nil {
|
||||
return existing.state != stateDraining && existing.fence == fence
|
||||
}
|
||||
if admission.ingressInUse >= admission.ingressCapacity {
|
||||
return false
|
||||
}
|
||||
if room != nil {
|
||||
for _, publication := range room.All() {
|
||||
if publication.state == stateReserved {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resources := room
|
||||
if resources == nil {
|
||||
resources = &roomPublications{}
|
||||
}
|
||||
resources.Set(publicationKey(fence), &publicationEntry{
|
||||
fence: fence,
|
||||
state: stateReserved,
|
||||
subscriptions: map[string]*subscriptionEntry{},
|
||||
})
|
||||
admission.rooms.Set(fence.RoomID, resources)
|
||||
admission.ingressInUse++
|
||||
return true
|
||||
}
|
||||
|
||||
// ReserveSubscription ports reserveSubscription.
|
||||
func (admission *Admission) ReserveSubscription(fence SubscriptionFence) bool {
|
||||
assertSubscriptionFence(fence)
|
||||
publication := admission.publication(fence.ResourceFence)
|
||||
if publication == nil || publication.state == stateDraining {
|
||||
return false
|
||||
}
|
||||
if existing, ok := publication.subscriptions[fence.ViewerPeerID]; ok {
|
||||
if existing.fence != fence {
|
||||
return false
|
||||
}
|
||||
if existing.state == stateReserved || existing.state == stateCommitted {
|
||||
return true
|
||||
}
|
||||
if publication.state != stateCommitted {
|
||||
return false
|
||||
}
|
||||
existing.state = stateReserved
|
||||
return true
|
||||
}
|
||||
if admission.egressInUse >= admission.egressCapacity {
|
||||
return false
|
||||
}
|
||||
|
||||
publication.subscriptions[fence.ViewerPeerID] = &subscriptionEntry{
|
||||
fence: fence,
|
||||
state: stateReserved,
|
||||
}
|
||||
admission.egressInUse++
|
||||
return true
|
||||
}
|
||||
|
||||
// CommitPublication ports commitPublication. The second result is false where
|
||||
// the TypeScript returns null; an empty slice with true is its empty array.
|
||||
func (admission *Admission) CommitPublication(fence ResourceFence) ([]ResourceFence, bool) {
|
||||
assertFence(fence)
|
||||
room, _ := admission.rooms.Get(fence.RoomID)
|
||||
var publication *publicationEntry
|
||||
if room != nil {
|
||||
publication, _ = room.Get(publicationKey(fence))
|
||||
}
|
||||
if room == nil || publication == nil || publication.fence != fence {
|
||||
return nil, false
|
||||
}
|
||||
if publication.state == stateCommitted {
|
||||
return nil, true
|
||||
}
|
||||
if publication.state != stateReserved {
|
||||
return nil, false
|
||||
}
|
||||
reserved := false
|
||||
for _, subscription := range publication.subscriptions {
|
||||
if subscription.state == stateReserved {
|
||||
reserved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reserved {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var draining []ResourceFence
|
||||
for _, other := range room.All() {
|
||||
if other == publication || other.state != stateCommitted {
|
||||
continue
|
||||
}
|
||||
markDraining(other)
|
||||
draining = append(draining, other.fence)
|
||||
}
|
||||
publication.state = stateCommitted
|
||||
for _, subscription := range publication.subscriptions {
|
||||
if subscription.state == stateReserved {
|
||||
subscription.state = stateCommitted
|
||||
}
|
||||
}
|
||||
return draining, true
|
||||
}
|
||||
|
||||
// CommitSubscription ports commitSubscription.
|
||||
func (admission *Admission) CommitSubscription(fence SubscriptionFence) bool {
|
||||
assertSubscriptionFence(fence)
|
||||
publication := admission.publication(fence.ResourceFence)
|
||||
if publication == nil || publication.state != stateCommitted {
|
||||
return false
|
||||
}
|
||||
subscription, ok := publication.subscriptions[fence.ViewerPeerID]
|
||||
if !ok || subscription.fence != fence {
|
||||
return false
|
||||
}
|
||||
if subscription.state == stateCommitted {
|
||||
return true
|
||||
}
|
||||
if subscription.state != stateReserved {
|
||||
return false
|
||||
}
|
||||
subscription.state = stateCommitted
|
||||
return true
|
||||
}
|
||||
|
||||
// BeginSubscriptionDrain ports beginSubscriptionDrain.
|
||||
func (admission *Admission) BeginSubscriptionDrain(fence SubscriptionFence) bool {
|
||||
assertSubscriptionFence(fence)
|
||||
publication := admission.publication(fence.ResourceFence)
|
||||
if publication == nil {
|
||||
return false
|
||||
}
|
||||
subscription, ok := publication.subscriptions[fence.ViewerPeerID]
|
||||
if !ok || subscription.fence != fence {
|
||||
return false
|
||||
}
|
||||
subscription.state = stateDraining
|
||||
return true
|
||||
}
|
||||
|
||||
// BeginDrain ports beginDrain.
|
||||
func (admission *Admission) BeginDrain(fence ResourceFence) bool {
|
||||
assertFence(fence)
|
||||
publication := admission.publication(fence)
|
||||
if publication == nil {
|
||||
return false
|
||||
}
|
||||
markDraining(publication)
|
||||
return true
|
||||
}
|
||||
|
||||
// CompleteDrain ports completeDrain.
|
||||
func (admission *Admission) CompleteDrain(fence ResourceFence) bool {
|
||||
assertFence(fence)
|
||||
room, _ := admission.rooms.Get(fence.RoomID)
|
||||
key := publicationKey(fence)
|
||||
var publication *publicationEntry
|
||||
if room != nil {
|
||||
publication, _ = room.Get(key)
|
||||
}
|
||||
if room == nil || publication == nil ||
|
||||
publication.state != stateDraining || publication.fence != fence {
|
||||
return false
|
||||
}
|
||||
|
||||
room.Delete(key)
|
||||
admission.ingressInUse--
|
||||
admission.subtractEgress(len(publication.subscriptions))
|
||||
admission.assertNoUnderflow()
|
||||
if room.Len() == 0 {
|
||||
admission.rooms.Delete(fence.RoomID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BeginDrainRoom ports beginDrainRoom.
|
||||
func (admission *Admission) BeginDrainRoom(roomID string) []ResourceFence {
|
||||
if roomID == "" {
|
||||
panic("SFU resource room ID is invalid")
|
||||
}
|
||||
room, _ := admission.rooms.Get(roomID)
|
||||
if room == nil {
|
||||
return nil
|
||||
}
|
||||
for _, publication := range room.All() {
|
||||
markDraining(publication)
|
||||
}
|
||||
fences := make([]ResourceFence, 0, room.Len())
|
||||
for _, publication := range room.All() {
|
||||
fences = append(fences, publication.fence)
|
||||
}
|
||||
return fences
|
||||
}
|
||||
|
||||
// BeginDrainAll ports beginDrainAll.
|
||||
func (admission *Admission) BeginDrainAll() []ResourceFence {
|
||||
var fences []ResourceFence
|
||||
for _, roomID := range admission.rooms.Keys() {
|
||||
fences = append(fences, admission.BeginDrainRoom(roomID)...)
|
||||
}
|
||||
return fences
|
||||
}
|
||||
|
||||
// Usage ports usage().
|
||||
func (admission *Admission) Usage() Usage {
|
||||
return Usage{Ingress: admission.ingressInUse, Egress: admission.egressInUse}
|
||||
}
|
||||
|
||||
func (admission *Admission) publication(fence ResourceFence) *publicationEntry {
|
||||
room, ok := admission.rooms.Get(fence.RoomID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
publication, ok := room.Get(publicationKey(fence))
|
||||
if !ok || publication.fence != fence {
|
||||
return nil
|
||||
}
|
||||
return publication
|
||||
}
|
||||
|
||||
func (admission *Admission) subtractEgress(count int) {
|
||||
admission.egressInUse -= count
|
||||
admission.assertNoUnderflow()
|
||||
}
|
||||
|
||||
func (admission *Admission) assertNoUnderflow() {
|
||||
if admission.ingressInUse < 0 || admission.egressInUse < 0 {
|
||||
panic("SFU resource accounting underflow")
|
||||
}
|
||||
}
|
||||
|
||||
func markDraining(publication *publicationEntry) {
|
||||
publication.state = stateDraining
|
||||
for _, subscription := range publication.subscriptions {
|
||||
subscription.state = stateDraining
|
||||
}
|
||||
}
|
||||
|
||||
func publicationKey(fence ResourceFence) string {
|
||||
return fence.ShareGeneration + "\x00" + fence.PublicationGeneration
|
||||
}
|
||||
|
||||
func assertFence(fence ResourceFence) {
|
||||
if fence.RoomID == "" || fence.ShareGeneration == "" ||
|
||||
fence.PublicationGeneration == "" {
|
||||
panic("SFU resource fence is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionFence(fence SubscriptionFence) {
|
||||
assertFence(fence.ResourceFence)
|
||||
if fence.ViewerPeerID == "" {
|
||||
panic("SFU subscription fence is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// assertPositiveSafeInteger ports assertPositiveSafeInteger. Go's int makes the
|
||||
// TypeScript Number.isSafeInteger fractional case unrepresentable; only the
|
||||
// range checks survive.
|
||||
func assertPositiveSafeInteger(value int, name string) {
|
||||
if value <= 0 || int64(value) > protocol.MaxSafeInteger {
|
||||
panic(name + " must be a positive safe integer")
|
||||
}
|
||||
}
|
||||
|
||||
const managedRoomPrefix = "screener-v1."
|
||||
|
||||
// roomIDPattern is ROOM_ID_PATTERN, /^[1-9]\d{0,11}$/. Go's \d and $ are the
|
||||
// ASCII digits and end of text, as they are in a non-unicode, non-multiline
|
||||
// JavaScript regular expression. OPAQUE_ID_PATTERN is protocol.ValidOpaqueID.
|
||||
var roomIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,11}$`)
|
||||
|
||||
var errInvalidRoomFence = errors.New("Managed LiveKit room fence is invalid")
|
||||
|
||||
// ManagedRoomName ports managedSfuRoomName. An invalid fence panics where the
|
||||
// TypeScript throws, because the router builds room-name keys from it in
|
||||
// synchronous code that never caught the throw. The RoomService calls, whose
|
||||
// TypeScript throw was caught by the router, use the error-returning form.
|
||||
func ManagedRoomName(fence ResourceFence) string {
|
||||
name, err := managedRoomName(fence)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func managedRoomName(fence ResourceFence) (string, error) {
|
||||
if !roomIDPattern.MatchString(fence.RoomID) ||
|
||||
!protocol.ValidOpaqueID(fence.ShareGeneration) ||
|
||||
!protocol.ValidOpaqueID(fence.PublicationGeneration) {
|
||||
return "", errInvalidRoomFence
|
||||
}
|
||||
return managedRoomPrefix + fence.RoomID + "." +
|
||||
fence.ShareGeneration + "." + fence.PublicationGeneration, nil
|
||||
}
|
||||
|
||||
// IsManagedRoomName ports isManagedSfuRoomName.
|
||||
func IsManagedRoomName(roomName string) bool {
|
||||
rest, ok := strings.CutPrefix(roomName, managedRoomPrefix)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(rest, ".")
|
||||
return len(parts) == 3 &&
|
||||
roomIDPattern.MatchString(parts[0]) &&
|
||||
protocol.ValidOpaqueID(parts[1]) &&
|
||||
protocol.ValidOpaqueID(parts[2])
|
||||
}
|
||||
|
||||
// viewerIdentity ports managedSfuViewerIdentity.
|
||||
func viewerIdentity(peerID string) (string, error) {
|
||||
if !protocol.ValidOpaqueID(peerID) {
|
||||
return "", errors.New("Managed LiveKit Viewer identity is invalid")
|
||||
}
|
||||
return "viewer:" + peerID, nil
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package sfu
|
||||
|
||||
// Ported from tests/sfu-resource-admission.test.ts.
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
func publicationFence(roomID string, publicationGeneration string) ResourceFence {
|
||||
return ResourceFence{
|
||||
RoomID: roomID,
|
||||
ShareGeneration: "share_" + roomID,
|
||||
PublicationGeneration: publicationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionFence(fence ResourceFence, viewerPeerID string) SubscriptionFence {
|
||||
return SubscriptionFence{ResourceFence: fence, ViewerPeerID: viewerPeerID}
|
||||
}
|
||||
|
||||
func expectPanic(t *testing.T, want string, run func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
message, _ := recovered.(string)
|
||||
if recovered == nil || !strings.Contains(message, want) {
|
||||
t.Fatalf("panic = %v, want it to contain %q", recovered, want)
|
||||
}
|
||||
}()
|
||||
run()
|
||||
}
|
||||
|
||||
func expectUsage(t *testing.T, admission *Admission, ingress int, egress int) {
|
||||
t.Helper()
|
||||
if usage := admission.Usage(); usage != (Usage{Ingress: ingress, Egress: egress}) {
|
||||
t.Fatalf("usage = %+v, want {Ingress:%d Egress:%d}", usage, ingress, egress)
|
||||
}
|
||||
}
|
||||
|
||||
func expectDrained(t *testing.T, drained []ResourceFence, ok bool, want ...ResourceFence) {
|
||||
t.Helper()
|
||||
if !ok {
|
||||
t.Fatal("commit was refused, want it accepted")
|
||||
}
|
||||
if !slices.Equal(drained, want) {
|
||||
t.Fatalf("drained = %v, want %v", drained, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionRequiresExplicitCapacities(t *testing.T) {
|
||||
// Number.MAX_SAFE_INTEGER + 1 and 0; the TypeScript third case, 1.5, cannot
|
||||
// be expressed by Go's int.
|
||||
for _, ingressCapacity := range []int{0, protocol.MaxSafeInteger + 1} {
|
||||
expectPanic(t, "positive safe integer", func() {
|
||||
NewAdmission(AdmissionOptions{IngressCapacity: ingressCapacity, EgressCapacity: 1})
|
||||
})
|
||||
}
|
||||
expectPanic(t, "positive safe integer", func() {
|
||||
NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 0})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdmissionEnforcesIngressAndEgressIndependently(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 2, EgressCapacity: 2})
|
||||
first := publicationFence("1", "publication_first")
|
||||
second := publicationFence("2", "publication_second")
|
||||
|
||||
if !admission.ReservePublication(first) || !admission.ReservePublication(second) {
|
||||
t.Fatal("both publications must fit the ingress capacity")
|
||||
}
|
||||
if admission.ReservePublication(publicationFence("3", "publication_third")) {
|
||||
t.Fatal("a third publication must exceed the ingress capacity")
|
||||
}
|
||||
if !admission.ReserveSubscription(subscriptionFence(first, "viewer_a")) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(first, "viewer_b")) {
|
||||
t.Fatal("both subscriptions must fit the egress capacity")
|
||||
}
|
||||
if admission.ReserveSubscription(subscriptionFence(second, "viewer_c")) {
|
||||
t.Fatal("a third subscription must exceed the egress capacity")
|
||||
}
|
||||
expectUsage(t, admission, 2, 2)
|
||||
}
|
||||
|
||||
func TestAdmissionKeepsExactOperationsIdempotent(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
active := publicationFence("1", "publication_active")
|
||||
viewer := subscriptionFence(active, "viewer_a")
|
||||
|
||||
if !admission.ReservePublication(active) || !admission.ReservePublication(active) {
|
||||
t.Fatal("an exact publication reservation must be idempotent")
|
||||
}
|
||||
if !admission.ReserveSubscription(viewer) || !admission.ReserveSubscription(viewer) {
|
||||
t.Fatal("an exact subscription reservation must be idempotent")
|
||||
}
|
||||
if admission.CommitSubscription(viewer) {
|
||||
t.Fatal("a subscription must not commit before its publication")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
drained, ok = admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
if !admission.CommitSubscription(viewer) {
|
||||
t.Fatal("the exact subscription must commit after its publication")
|
||||
}
|
||||
expectUsage(t, admission, 1, 1)
|
||||
|
||||
stale := publicationFence("1", "publication_stale")
|
||||
if admission.ReserveSubscription(subscriptionFence(stale, "viewer_a")) {
|
||||
t.Fatal("a stale generation must not admit a subscription")
|
||||
}
|
||||
if _, ok := admission.CommitPublication(stale); ok {
|
||||
t.Fatal("a stale generation must not commit")
|
||||
}
|
||||
if admission.CommitSubscription(subscriptionFence(active, "viewer_b")) {
|
||||
t.Fatal("an unreserved viewer must not commit")
|
||||
}
|
||||
if admission.BeginSubscriptionDrain(subscriptionFence(active, "viewer_b")) {
|
||||
t.Fatal("an unreserved viewer must not drain")
|
||||
}
|
||||
if admission.BeginDrain(stale) {
|
||||
t.Fatal("a stale generation must not drain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionDoesNotCommitWithoutFirstSubscription(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
pending := publicationFence("1", "publication_pending")
|
||||
|
||||
if !admission.ReservePublication(pending) {
|
||||
t.Fatal("the publication must reserve")
|
||||
}
|
||||
if _, ok := admission.CommitPublication(pending); ok {
|
||||
t.Fatal("a publication without a reserved subscription must not commit")
|
||||
}
|
||||
if !admission.BeginDrain(pending) || !admission.CompleteDrain(pending) {
|
||||
t.Fatal("the reserved publication must drain")
|
||||
}
|
||||
expectUsage(t, admission, 0, 0)
|
||||
}
|
||||
|
||||
func TestAdmissionCommitsNewGenerationBeforeDrainingTheOld(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 2, EgressCapacity: 3})
|
||||
first := publicationFence("1", "publication_first")
|
||||
second := publicationFence("1", "publication_second")
|
||||
firstViewer := subscriptionFence(first, "viewer_a")
|
||||
|
||||
if !admission.ReservePublication(first) || !admission.ReserveSubscription(firstViewer) {
|
||||
t.Fatal("the first generation must reserve")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(first)
|
||||
expectDrained(t, drained, ok)
|
||||
|
||||
if !admission.ReservePublication(second) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(second, "viewer_b")) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(second, "viewer_c")) {
|
||||
t.Fatal("the second generation must reserve alongside the first")
|
||||
}
|
||||
expectUsage(t, admission, 2, 3)
|
||||
|
||||
drained, ok = admission.CommitPublication(second)
|
||||
expectDrained(t, drained, ok, first)
|
||||
if !admission.CommitSubscription(subscriptionFence(second, "viewer_b")) {
|
||||
t.Fatal("the new generation's subscription must commit")
|
||||
}
|
||||
if admission.ReservePublication(first) {
|
||||
t.Fatal("the draining generation must not be reserved again")
|
||||
}
|
||||
if admission.ReserveSubscription(firstViewer) {
|
||||
t.Fatal("the draining generation must not admit a subscription")
|
||||
}
|
||||
|
||||
if !admission.CompleteDrain(first) {
|
||||
t.Fatal("the drained generation must complete")
|
||||
}
|
||||
if admission.CompleteDrain(first) {
|
||||
t.Fatal("a completed drain must not repeat")
|
||||
}
|
||||
expectUsage(t, admission, 1, 2)
|
||||
}
|
||||
|
||||
func TestAdmissionKeepsReleasedReservedSubscriptionCharged(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 2})
|
||||
active := publicationFence("1", "publication_active")
|
||||
first := subscriptionFence(active, "viewer_a")
|
||||
candidate := subscriptionFence(active, "viewer_b")
|
||||
|
||||
if !admission.ReservePublication(active) || !admission.ReserveSubscription(first) {
|
||||
t.Fatal("the publication and its first viewer must reserve")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
|
||||
if !admission.ReserveSubscription(candidate) {
|
||||
t.Fatal("the candidate must reserve")
|
||||
}
|
||||
expectUsage(t, admission, 1, 2)
|
||||
if !admission.BeginSubscriptionDrain(candidate) ||
|
||||
!admission.BeginSubscriptionDrain(candidate) {
|
||||
t.Fatal("the subscription drain must be idempotent")
|
||||
}
|
||||
if admission.CommitSubscription(candidate) {
|
||||
t.Fatal("a draining subscription must not commit")
|
||||
}
|
||||
expectUsage(t, admission, 1, 2)
|
||||
if admission.ReserveSubscription(subscriptionFence(active, "viewer_c")) {
|
||||
t.Fatal("the draining subscription still holds its egress charge")
|
||||
}
|
||||
if !admission.BeginDrain(active) || !admission.CompleteDrain(active) {
|
||||
t.Fatal("the publication must drain")
|
||||
}
|
||||
expectUsage(t, admission, 0, 0)
|
||||
}
|
||||
|
||||
func TestAdmissionReactivatesExactDrainingSubscription(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
active := publicationFence("1", "publication_active")
|
||||
viewer := subscriptionFence(active, "viewer_a")
|
||||
|
||||
if !admission.ReservePublication(active) || !admission.ReserveSubscription(viewer) {
|
||||
t.Fatal("the publication and its viewer must reserve")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
if !admission.BeginSubscriptionDrain(viewer) {
|
||||
t.Fatal("the subscription must begin draining")
|
||||
}
|
||||
expectUsage(t, admission, 1, 1)
|
||||
|
||||
if !admission.ReserveSubscription(viewer) || !admission.ReserveSubscription(viewer) {
|
||||
t.Fatal("the exact draining subscription must reactivate")
|
||||
}
|
||||
expectUsage(t, admission, 1, 1)
|
||||
if !admission.BeginSubscriptionDrain(viewer) {
|
||||
t.Fatal("the subscription must drain again")
|
||||
}
|
||||
if admission.ReserveSubscription(subscriptionFence(active, "viewer_b")) {
|
||||
t.Fatal("the charge must still block another viewer")
|
||||
}
|
||||
if !admission.ReserveSubscription(viewer) || !admission.CommitSubscription(viewer) {
|
||||
t.Fatal("the exact subscription must reactivate and commit")
|
||||
}
|
||||
expectUsage(t, admission, 1, 1)
|
||||
}
|
||||
|
||||
func TestAdmissionKeepsDepartedSubscriptionChargedUntilDrainProof(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
active := publicationFence("1", "publication_active")
|
||||
viewer := subscriptionFence(active, "viewer_a")
|
||||
|
||||
if !admission.ReservePublication(active) || !admission.ReserveSubscription(viewer) {
|
||||
t.Fatal("the publication and its viewer must reserve")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
if !admission.BeginSubscriptionDrain(viewer) {
|
||||
t.Fatal("the subscription must begin draining")
|
||||
}
|
||||
if admission.ReserveSubscription(subscriptionFence(active, "viewer_b")) {
|
||||
t.Fatal("the departed viewer still holds its egress charge")
|
||||
}
|
||||
if !admission.BeginDrain(active) || !admission.BeginDrain(active) {
|
||||
t.Fatal("the publication drain must be idempotent")
|
||||
}
|
||||
expectUsage(t, admission, 1, 1)
|
||||
if !admission.CompleteDrain(active) {
|
||||
t.Fatal("the publication must complete its drain")
|
||||
}
|
||||
expectUsage(t, admission, 0, 0)
|
||||
}
|
||||
|
||||
func TestAdmissionDrainsEveryGenerationExactlyOnce(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 3, EgressCapacity: 4})
|
||||
active := publicationFence("1", "publication_active")
|
||||
replacement := publicationFence("1", "publication_replacement")
|
||||
other := publicationFence("2", "publication_other")
|
||||
|
||||
if !admission.ReservePublication(active) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(active, "viewer_a")) {
|
||||
t.Fatal("the active generation must reserve")
|
||||
}
|
||||
drained, ok := admission.CommitPublication(active)
|
||||
expectDrained(t, drained, ok)
|
||||
if !admission.ReservePublication(replacement) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(replacement, "viewer_b")) ||
|
||||
!admission.ReservePublication(other) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(other, "viewer_c")) {
|
||||
t.Fatal("the replacement and the other room must reserve")
|
||||
}
|
||||
|
||||
// Insertion order, not map order: the drain tasks are created from it.
|
||||
firstRoom := admission.BeginDrainRoom("1")
|
||||
if !slices.Equal(firstRoom, []ResourceFence{active, replacement}) {
|
||||
t.Fatalf("beginDrainRoom = %v, want [active replacement]", firstRoom)
|
||||
}
|
||||
if repeated := admission.BeginDrainRoom("1"); !slices.Equal(repeated, firstRoom) {
|
||||
t.Fatalf("repeated beginDrainRoom = %v, want %v", repeated, firstRoom)
|
||||
}
|
||||
all := admission.BeginDrainAll()
|
||||
if !slices.Equal(all, []ResourceFence{active, replacement, other}) {
|
||||
t.Fatalf("beginDrainAll = %v, want [active replacement other]", all)
|
||||
}
|
||||
expectUsage(t, admission, 3, 3)
|
||||
|
||||
for _, fence := range []ResourceFence{active, replacement, other} {
|
||||
if !admission.CompleteDrain(fence) {
|
||||
t.Fatalf("%s must complete its drain", fence.PublicationGeneration)
|
||||
}
|
||||
if admission.CompleteDrain(fence) {
|
||||
t.Fatalf("%s must not complete twice", fence.PublicationGeneration)
|
||||
}
|
||||
}
|
||||
expectUsage(t, admission, 0, 0)
|
||||
}
|
||||
|
||||
func TestAdmissionRejectsInvalidFences(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
valid := publicationFence("1", "publication_active")
|
||||
|
||||
expectPanic(t, "SFU resource fence is invalid", func() {
|
||||
admission.ReservePublication(ResourceFence{ShareGeneration: "s", PublicationGeneration: "p"})
|
||||
})
|
||||
expectPanic(t, "SFU resource fence is invalid", func() {
|
||||
admission.BeginDrain(ResourceFence{RoomID: "1", PublicationGeneration: "p"})
|
||||
})
|
||||
expectPanic(t, "SFU subscription fence is invalid", func() {
|
||||
admission.ReserveSubscription(subscriptionFence(valid, ""))
|
||||
})
|
||||
expectPanic(t, "SFU resource room ID is invalid", func() {
|
||||
admission.BeginDrainRoom("")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAdmissionAssertsNoUnderflow reaches the accounting assertion the public
|
||||
// state machine cannot reach, by corrupting the egress counter first.
|
||||
func TestAdmissionAssertsNoUnderflow(t *testing.T) {
|
||||
admission := NewAdmission(AdmissionOptions{IngressCapacity: 1, EgressCapacity: 1})
|
||||
active := publicationFence("1", "publication_active")
|
||||
if !admission.ReservePublication(active) ||
|
||||
!admission.ReserveSubscription(subscriptionFence(active, "viewer_a")) ||
|
||||
!admission.BeginDrain(active) {
|
||||
t.Fatal("the publication must reserve and drain")
|
||||
}
|
||||
admission.egressInUse = 0
|
||||
expectPanic(t, "SFU resource accounting underflow", func() {
|
||||
admission.CompleteDrain(active)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManagedRoomName(t *testing.T) {
|
||||
fence := ResourceFence{
|
||||
RoomID: "42",
|
||||
ShareGeneration: "share_generation_12345678",
|
||||
PublicationGeneration: "publication_generation_12345678",
|
||||
}
|
||||
name := ManagedRoomName(fence)
|
||||
const want = "screener-v1.42.share_generation_12345678.publication_generation_12345678"
|
||||
if name != want {
|
||||
t.Fatalf("ManagedRoomName = %q, want %q", name, want)
|
||||
}
|
||||
if !IsManagedRoomName(name) {
|
||||
t.Fatal("the generated name must be managed")
|
||||
}
|
||||
|
||||
for _, roomName := range []string{
|
||||
"screener-v1.42.bad.segment.extra",
|
||||
"another-application",
|
||||
"screener-v1.42.share_generation_12345678",
|
||||
"screener-v1.042.share_generation_12345678.publication_generation_12345678",
|
||||
"screener-v1.1234567890123.share_generation_12345678.publication_generation_12345678",
|
||||
"screener-v1.42.short.publication_generation_12345678",
|
||||
} {
|
||||
if IsManagedRoomName(roomName) {
|
||||
t.Fatalf("%q must not be a managed room name", roomName)
|
||||
}
|
||||
}
|
||||
|
||||
for _, invalid := range []ResourceFence{
|
||||
{RoomID: "0", ShareGeneration: "share_generation_12345678", PublicationGeneration: "publication_generation_12345678"},
|
||||
{RoomID: "42", ShareGeneration: "has.dot.12345678", PublicationGeneration: "publication_generation_12345678"},
|
||||
{RoomID: "42", ShareGeneration: "share_generation_12345678", PublicationGeneration: "short"},
|
||||
} {
|
||||
expectPanic(t, "Managed LiveKit room fence is invalid", func() {
|
||||
ManagedRoomName(invalid)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package sfu
|
||||
|
||||
// Ported from src/server/sfu-room-control.ts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// RoomControl is SfuRoomControl. Every method performs network I/O, so it must
|
||||
// be called with the signaling server's mutex released.
|
||||
type RoomControl interface {
|
||||
Initialize(ctx context.Context) error
|
||||
CreateRoom(ctx context.Context, fence ResourceFence) error
|
||||
DeleteRoom(ctx context.Context, fence ResourceFence) error
|
||||
DrainSubscription(ctx context.Context, fence SubscriptionFence) error
|
||||
HostParticipantExists(ctx context.Context, fence ResourceFence) (bool, error)
|
||||
}
|
||||
|
||||
// LiveKitRoomControlOptions is LiveKitSfuRoomControlOptions minus the injected
|
||||
// roomService: the tests drive a real Twirp client against httptest.
|
||||
type LiveKitRoomControlOptions struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
APISecret string
|
||||
MaxViewersPerRoom int
|
||||
}
|
||||
|
||||
// LiveKitRoomControl is LiveKitSfuRoomControl. It holds no state of its own:
|
||||
// the TypeScript per-room-name operation queue moved to the router, which
|
||||
// reserves each managed room's turn while the signaling mutex is still held
|
||||
// and so orders the mutating calls by the decision that made them.
|
||||
type LiveKitRoomControl struct {
|
||||
service roomService
|
||||
maxParticipants int
|
||||
}
|
||||
|
||||
var _ RoomControl = (*LiveKitRoomControl)(nil)
|
||||
|
||||
// NewLiveKitRoomControl mirrors the LiveKitSfuRoomControl constructor.
|
||||
func NewLiveKitRoomControl(options LiveKitRoomControlOptions) (*LiveKitRoomControl, error) {
|
||||
if options.APIURL == "" || options.APIKey == "" ||
|
||||
len(options.APISecret) < minAPISecretBytes {
|
||||
return nil, errors.New("LiveKit RoomService configuration is invalid")
|
||||
}
|
||||
if err := validateViewerLimit(options.MaxViewersPerRoom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LiveKitRoomControl{
|
||||
service: newRoomService(options.APIURL, newSigner(options.APIKey, options.APISecret)),
|
||||
maxParticipants: options.MaxViewersPerRoom + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Initialize ports initialize(): it deletes in the order listRooms returned.
|
||||
func (control *LiveKitRoomControl) Initialize(ctx context.Context) error {
|
||||
rooms, err := control.service.listRooms(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if !IsManagedRoomName(room.Name) {
|
||||
return errors.New("Dedicated LiveKit instance contains a foreign room")
|
||||
}
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if err := control.deleteRoomByName(ctx, room.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
remaining, err := control.service.listRooms(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
return errors.New("Dedicated LiveKit instance is not empty after startup drain")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRoom ports createRoom().
|
||||
func (control *LiveKitRoomControl) CreateRoom(ctx context.Context, fence ResourceFence) error {
|
||||
roomName, err := managedRoomName(fence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := control.service.listRooms(ctx, []string{roomName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) != 0 {
|
||||
return errors.New("Managed LiveKit room already exists")
|
||||
}
|
||||
if err := control.service.createRoom(ctx, roomName, control.maxParticipants); err != nil {
|
||||
return err
|
||||
}
|
||||
rooms, err := control.service.listRooms(ctx, []string{roomName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rooms) != 1 || rooms[0].Name != roomName {
|
||||
return errors.New("Managed LiveKit room creation was not confirmed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRoom ports deleteRoom().
|
||||
func (control *LiveKitRoomControl) DeleteRoom(ctx context.Context, fence ResourceFence) error {
|
||||
roomName, err := managedRoomName(fence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := control.deleteRoomByName(ctx, roomName); err != nil {
|
||||
return err
|
||||
}
|
||||
rooms, err := control.service.listRooms(ctx, []string{roomName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rooms) != 0 {
|
||||
return errors.New("Managed LiveKit room deletion was not confirmed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DrainSubscription ports drainSubscription().
|
||||
func (control *LiveKitRoomControl) DrainSubscription(ctx context.Context, fence SubscriptionFence) error {
|
||||
roomName, err := managedRoomName(fence.ResourceFence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, err := viewerIdentity(fence.ViewerPeerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := control.service.removeParticipant(ctx, roomName, identity); err != nil &&
|
||||
!isNotFound(err) {
|
||||
return err
|
||||
}
|
||||
exists, err := control.participantExists(ctx, roomName, identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errors.New("Managed LiveKit participant removal was not confirmed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HostParticipantExists ports hostParticipantExists().
|
||||
func (control *LiveKitRoomControl) HostParticipantExists(
|
||||
ctx context.Context,
|
||||
fence ResourceFence,
|
||||
) (bool, error) {
|
||||
roomName, err := managedRoomName(fence)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return control.participantExists(ctx, roomName, "host")
|
||||
}
|
||||
|
||||
// deleteRoomByName ports deleteRoomByName(): a missing room is not an error.
|
||||
func (control *LiveKitRoomControl) deleteRoomByName(ctx context.Context, roomName string) error {
|
||||
if err := control.service.deleteRoom(ctx, roomName); err != nil && !isNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// participantExists ports participantExists(): an absent room reads as absent.
|
||||
func (control *LiveKitRoomControl) participantExists(
|
||||
ctx context.Context,
|
||||
roomName string,
|
||||
identity string,
|
||||
) (bool, error) {
|
||||
participants, err := control.service.listParticipants(ctx, roomName)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
for _, participant := range participants {
|
||||
if participant.Identity == identity {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package sfu
|
||||
|
||||
// Ported from tests/sfu-room-control.test.ts. The TypeScript injected a
|
||||
// FakeRoomService; here the real Twirp client runs against an httptest server
|
||||
// that reproduces that fake's behaviour, so the wire contract recorded in
|
||||
// evidence/livekit.md section 3 is covered by the same scenarios.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
)
|
||||
|
||||
// fakeLiveKit is tests/sfu-room-control.test.ts's FakeRoomService behind the
|
||||
// real Twirp endpoints.
|
||||
type fakeLiveKit struct {
|
||||
mu sync.Mutex
|
||||
// Ordering site: listRooms answers in insertion order, which initialize
|
||||
// turns into its delete order.
|
||||
rooms ordered.Map[string, map[string]struct{}]
|
||||
// operations records the mutating calls, as the TypeScript fake did;
|
||||
// listCalls counts the reads it did not record.
|
||||
operations []string
|
||||
listCalls int
|
||||
createOptions []int
|
||||
retainDeletedRoom bool
|
||||
retainRemovedParticipant bool
|
||||
}
|
||||
|
||||
func newFakeLiveKit(t *testing.T) (*fakeLiveKit, string) {
|
||||
t.Helper()
|
||||
fake := &fakeLiveKit{}
|
||||
server := httptest.NewServer(http.HandlerFunc(fake.serve))
|
||||
t.Cleanup(server.Close)
|
||||
return fake, server.URL
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) serve(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Names []string `json:"names"`
|
||||
Room string `json:"room"`
|
||||
Identity string `json:"identity"`
|
||||
MaxParticipants int `json:"maxParticipants"`
|
||||
}
|
||||
_ = json.NewDecoder(request.Body).Decode(&body)
|
||||
method := request.URL.Path[strings.LastIndexByte(request.URL.Path, '/')+1:]
|
||||
|
||||
status, payload := http.StatusOK, "{}"
|
||||
switch method {
|
||||
case "ListRooms":
|
||||
payload = fake.listRooms(body.Names)
|
||||
case "CreateRoom":
|
||||
status, payload = fake.createRoom(body.Name, body.MaxParticipants)
|
||||
case "DeleteRoom":
|
||||
status, payload = fake.deleteRoom(body.Room)
|
||||
case "ListParticipants":
|
||||
status, payload = fake.listParticipants(body.Room)
|
||||
case "RemoveParticipant":
|
||||
status, payload = fake.removeParticipant(body.Room, body.Identity)
|
||||
default:
|
||||
status, payload = http.StatusNotFound, `{"code":"bad_route","msg":"unknown method"}`
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write([]byte(payload))
|
||||
}
|
||||
|
||||
const notFoundBody = `{"code":"not_found","msg":"missing"}`
|
||||
|
||||
func (fake *fakeLiveKit) listRooms(names []string) string {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.listCalls++
|
||||
selected := make([]string, 0, fake.rooms.Len())
|
||||
for _, name := range fake.rooms.Keys() {
|
||||
if len(names) == 0 || slices.Contains(names, name) {
|
||||
selected = append(selected, `{"name":`+quote(name)+`}`)
|
||||
}
|
||||
}
|
||||
return `{"rooms":[` + strings.Join(selected, ",") + `]}`
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) createRoom(name string, maxParticipants int) (int, string) {
|
||||
fake.mu.Lock()
|
||||
fake.operations = append(fake.operations, "create:"+name)
|
||||
fake.createOptions = append(fake.createOptions, maxParticipants)
|
||||
defer fake.mu.Unlock()
|
||||
if fake.rooms.Has(name) {
|
||||
return http.StatusConflict, `{"code":"already_exists","msg":"room already exists"}`
|
||||
}
|
||||
fake.rooms.Set(name, map[string]struct{}{})
|
||||
return http.StatusOK, `{"name":` + quote(name) + `}`
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) deleteRoom(room string) (int, string) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.operations = append(fake.operations, "delete:"+room)
|
||||
if !fake.rooms.Has(room) {
|
||||
return http.StatusNotFound, notFoundBody
|
||||
}
|
||||
if !fake.retainDeletedRoom {
|
||||
fake.rooms.Delete(room)
|
||||
}
|
||||
return http.StatusOK, "{}"
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) listParticipants(room string) (int, string) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
participants, ok := fake.rooms.Get(room)
|
||||
if !ok {
|
||||
return http.StatusNotFound, notFoundBody
|
||||
}
|
||||
records := make([]string, 0, len(participants))
|
||||
for identity := range participants {
|
||||
records = append(records, `{"identity":`+quote(identity)+`}`)
|
||||
}
|
||||
return http.StatusOK, `{"participants":[` + strings.Join(records, ",") + `]}`
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) removeParticipant(room string, identity string) (int, string) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.operations = append(fake.operations, "remove:"+room+":"+identity)
|
||||
participants, ok := fake.rooms.Get(room)
|
||||
if !ok {
|
||||
return http.StatusNotFound, notFoundBody
|
||||
}
|
||||
if _, joined := participants[identity]; !joined {
|
||||
return http.StatusNotFound, notFoundBody
|
||||
}
|
||||
if !fake.retainRemovedParticipant {
|
||||
delete(participants, identity)
|
||||
}
|
||||
return http.StatusOK, "{}"
|
||||
}
|
||||
|
||||
// join is the TypeScript fake's join(): it fails when the room is absent.
|
||||
func (fake *fakeLiveKit) join(room string, identity string) bool {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
participants, ok := fake.rooms.Get(room)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
participants[identity] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) seed(room string, identities ...string) {
|
||||
participants := make(map[string]struct{}, len(identities))
|
||||
for _, identity := range identities {
|
||||
participants[identity] = struct{}{}
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.rooms.Set(room, participants)
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) snapshot() ([]string, []string) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return fake.rooms.Keys(), append([]string(nil), fake.operations...)
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) operationLog() []string {
|
||||
_, operations := fake.snapshot()
|
||||
return operations
|
||||
}
|
||||
|
||||
func (fake *fakeLiveKit) participants(room string) []string {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
found, ok := fake.rooms.Get(room)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
identities := make([]string, 0, len(found))
|
||||
for identity := range found {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
slices.Sort(identities)
|
||||
return identities
|
||||
}
|
||||
|
||||
func quote(value string) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func testControl(t *testing.T, apiURL string) *LiveKitRoomControl {
|
||||
t.Helper()
|
||||
control, err := NewLiveKitRoomControl(LiveKitRoomControlOptions{
|
||||
APIURL: apiURL,
|
||||
APIKey: testAPIKey,
|
||||
APISecret: testAPISecret,
|
||||
MaxViewersPerRoom: 8,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitRoomControl: %v", err)
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func testFence() ResourceFence {
|
||||
return ResourceFence{
|
||||
RoomID: "42",
|
||||
ShareGeneration: testShareGeneration,
|
||||
PublicationGeneration: "publication_generation_12345678",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlDrainsManagedRoomsBeforeStartup(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
current := ManagedRoomName(testFence())
|
||||
fake.seed(current, "host")
|
||||
|
||||
if err := testControl(t, apiURL).Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
rooms, operations := fake.snapshot()
|
||||
if len(rooms) != 0 {
|
||||
t.Fatalf("rooms = %v, want none", rooms)
|
||||
}
|
||||
if !slices.Equal(operations, []string{"delete:" + current}) {
|
||||
t.Fatalf("operations = %v", operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlRejectsForeignInstanceAndUnconfirmedDrain(t *testing.T) {
|
||||
foreign, foreignURL := newFakeLiveKit(t)
|
||||
foreign.seed("another-application")
|
||||
err := testControl(t, foreignURL).Initialize(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "foreign room") {
|
||||
t.Fatalf("Initialize error = %v, want a foreign room refusal", err)
|
||||
}
|
||||
if operations := foreign.operationLog(); len(operations) != 0 {
|
||||
t.Fatalf("operations = %v, want none", operations)
|
||||
}
|
||||
|
||||
retained, retainedURL := newFakeLiveKit(t)
|
||||
retained.retainDeletedRoom = true
|
||||
retained.seed(ManagedRoomName(testFence()))
|
||||
err = testControl(t, retainedURL).Initialize(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "not empty after startup drain") {
|
||||
t.Fatalf("Initialize error = %v, want an unconfirmed drain", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomControlInitializeAcceptsAnEmptyNamespace pins the confirmation call
|
||||
// itself: an already empty instance still asks a second time.
|
||||
func TestRoomControlInitializeAcceptsAnEmptyNamespace(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
if err := testControl(t, apiURL).Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if operations := fake.operationLog(); len(operations) != 0 {
|
||||
t.Fatalf("operations = %v, want none", operations)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.listCalls != 2 {
|
||||
t.Fatalf("listRooms calls = %d, want the drain and its confirmation", fake.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlRejectsOffLedgerRoom(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
fence := testFence()
|
||||
fake.seed(ManagedRoomName(fence), "host")
|
||||
|
||||
err := testControl(t, apiURL).CreateRoom(context.Background(), fence)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("CreateRoom error = %v, want an existing-room refusal", err)
|
||||
}
|
||||
if operations := fake.operationLog(); len(operations) != 0 {
|
||||
t.Fatalf("operations = %v, want none", operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlChecksExactHostParticipant(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
control := testControl(t, apiURL)
|
||||
fence := testFence()
|
||||
ctx := context.Background()
|
||||
if err := control.CreateRoom(ctx, fence); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
roomName := ManagedRoomName(fence)
|
||||
|
||||
if !fake.join(roomName, "viewer:one") {
|
||||
t.Fatal("the viewer must join")
|
||||
}
|
||||
expectHost(t, control, fence, false)
|
||||
if !fake.join(roomName, "host") {
|
||||
t.Fatal("the host must join")
|
||||
}
|
||||
expectHost(t, control, fence, true)
|
||||
if err := control.DeleteRoom(ctx, fence); err != nil {
|
||||
t.Fatalf("DeleteRoom: %v", err)
|
||||
}
|
||||
// An absent room reads as an offline host, not as an error.
|
||||
expectHost(t, control, fence, false)
|
||||
}
|
||||
|
||||
func TestRoomControlRemovesOnlyTheExactViewer(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
control := testControl(t, apiURL)
|
||||
fence := testFence()
|
||||
ctx := context.Background()
|
||||
if err := control.CreateRoom(ctx, fence); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
roomName := ManagedRoomName(fence)
|
||||
const viewerPeerID = "viewer_peer_12345678"
|
||||
for _, identity := range []string{"host", "viewer:" + viewerPeerID, "viewer:other_peer_12345678"} {
|
||||
if !fake.join(roomName, identity) {
|
||||
t.Fatalf("%s must join", identity)
|
||||
}
|
||||
}
|
||||
|
||||
subscription := SubscriptionFence{ResourceFence: fence, ViewerPeerID: viewerPeerID}
|
||||
if err := control.DrainSubscription(ctx, subscription); err != nil {
|
||||
t.Fatalf("DrainSubscription: %v", err)
|
||||
}
|
||||
if participants := fake.participants(roomName); !slices.Equal(participants,
|
||||
[]string{"host", "viewer:other_peer_12345678"}) {
|
||||
t.Fatalf("participants = %v", participants)
|
||||
}
|
||||
// The repeat drain is a not-found removal, which is tolerated.
|
||||
if err := control.DrainSubscription(ctx, subscription); err != nil {
|
||||
t.Fatalf("repeat DrainSubscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlRejectsUnconfirmedViewerDrain(t *testing.T) {
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
control := testControl(t, apiURL)
|
||||
fence := testFence()
|
||||
ctx := context.Background()
|
||||
if err := control.CreateRoom(ctx, fence); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
const viewerPeerID = "viewer_peer_12345678"
|
||||
fake.join(ManagedRoomName(fence), "viewer:"+viewerPeerID)
|
||||
fake.mu.Lock()
|
||||
fake.retainRemovedParticipant = true
|
||||
fake.mu.Unlock()
|
||||
|
||||
err := control.DrainSubscription(ctx,
|
||||
SubscriptionFence{ResourceFence: fence, ViewerPeerID: viewerPeerID})
|
||||
if err == nil || !strings.Contains(err.Error(), "removal was not confirmed") {
|
||||
t.Fatalf("DrainSubscription error = %v, want an unconfirmed removal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomControlRejectsInvalidConfiguration(t *testing.T) {
|
||||
for name, options := range map[string]LiveKitRoomControlOptions{
|
||||
"no url": {APIURL: "", APIKey: testAPIKey, APISecret: testAPISecret, MaxViewersPerRoom: 8},
|
||||
"no key": {APIURL: "http://127.0.0.1:7880", APIKey: "", APISecret: testAPISecret, MaxViewersPerRoom: 8},
|
||||
"short secret": {APIURL: "http://127.0.0.1:7880", APIKey: testAPIKey, APISecret: strings.Repeat("s", 31), MaxViewersPerRoom: 8},
|
||||
"no viewers": {APIURL: "http://127.0.0.1:7880", APIKey: testAPIKey, APISecret: testAPISecret, MaxViewersPerRoom: 0},
|
||||
"too many viewers": {
|
||||
APIURL: "http://127.0.0.1:7880", APIKey: testAPIKey,
|
||||
APISecret: testAPISecret, MaxViewersPerRoom: 21,
|
||||
},
|
||||
} {
|
||||
if _, err := NewLiveKitRoomControl(options); err == nil {
|
||||
t.Fatalf("%s: want a rejection", name)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid fence is refused without touching the service.
|
||||
fake, apiURL := newFakeLiveKit(t)
|
||||
control := testControl(t, apiURL)
|
||||
broken := ResourceFence{RoomID: "0", ShareGeneration: testShareGeneration,
|
||||
PublicationGeneration: "publication_generation_12345678"}
|
||||
if err := control.CreateRoom(context.Background(), broken); err == nil {
|
||||
t.Fatal("an invalid fence must be refused")
|
||||
}
|
||||
if err := control.DrainSubscription(context.Background(), SubscriptionFence{
|
||||
ResourceFence: testFence(), ViewerPeerID: "short",
|
||||
}); err == nil {
|
||||
t.Fatal("an invalid viewer identity must be refused")
|
||||
}
|
||||
if operations := fake.operationLog(); len(operations) != 0 {
|
||||
t.Fatalf("operations = %v, want none", operations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomControlPropagatesContextCancellation covers the ctx plumbing that
|
||||
// carries the 5 second per-request timeout.
|
||||
func TestRoomControlPropagatesContextCancellation(t *testing.T) {
|
||||
_, apiURL := newFakeLiveKit(t)
|
||||
control := testControl(t, apiURL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := control.CreateRoom(ctx, testFence()); err == nil {
|
||||
t.Fatal("a cancelled context must abort the call")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomControlAutoCreateStaysDisabled keeps the deployment contract the
|
||||
// TypeScript suite asserted next to these scenarios.
|
||||
func TestRoomControlAutoCreateStaysDisabled(t *testing.T) {
|
||||
configuration, err := os.ReadFile("../../../deploy/livekit/livekit.yaml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("read livekit.yaml.example: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`(?s)room:\s+.*auto_create: false`).Match(configuration) {
|
||||
t.Fatal("the tracked LiveKit deployment must keep auto_create disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func expectHost(t *testing.T, control *LiveKitRoomControl, fence ResourceFence, want bool) {
|
||||
t.Helper()
|
||||
exists, err := control.HostParticipantExists(context.Background(), fence)
|
||||
if err != nil {
|
||||
t.Fatalf("HostParticipantExists: %v", err)
|
||||
}
|
||||
if exists != want {
|
||||
t.Fatalf("HostParticipantExists = %v, want %v", exists, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package sfu
|
||||
|
||||
// Twirp/JSON client for livekit.RoomService, covering exactly the five RPCs
|
||||
// src/server/sfu-room-control.ts used through RoomServiceClient. The wire
|
||||
// contract is evidence/livekit.md section 3.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestTimeout is RoomServiceClient's `requestTimeout: 5`, applied per
|
||||
// HTTP request. There is no retry and no failover.
|
||||
requestTimeout = 5 * time.Second
|
||||
twirpPathPrefix = "/twirp/livekit.RoomService/"
|
||||
// maxResponseBytes bounds a hostile or broken RoomService reply; the four
|
||||
// decoded shapes are tiny.
|
||||
maxResponseBytes = 1 << 20
|
||||
)
|
||||
|
||||
// twirpError is the Twirp JSON error body {"code":..,"msg":..} plus the HTTP
|
||||
// status. It is unexported on purpose: the only question the server ever asks
|
||||
// is isNotFound, and the body must never be logged.
|
||||
type twirpError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (failure *twirpError) Error() string {
|
||||
return fmt.Sprintf("livekit %s: %s (HTTP %d)",
|
||||
failure.code, failure.message, failure.status)
|
||||
}
|
||||
|
||||
// isNotFound ports isNotFound(): HTTP 404 or the twirp code "not_found", and
|
||||
// nothing else, so a 409 already_exists still propagates.
|
||||
func isNotFound(err error) bool {
|
||||
var failure *twirpError
|
||||
return errors.As(err, &failure) &&
|
||||
(failure.status == http.StatusNotFound || failure.code == "not_found")
|
||||
}
|
||||
|
||||
type roomRecord struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type participantRecord struct {
|
||||
Identity string `json:"identity"`
|
||||
}
|
||||
|
||||
// roomService is the LiveKitRoomService interface of the TypeScript, bound to
|
||||
// the real RoomService rather than to an injected double.
|
||||
type roomService struct {
|
||||
baseURL string
|
||||
signer signer
|
||||
}
|
||||
|
||||
// newRoomService applies TwirpRpc's ws->http host rewrite and drops a trailing
|
||||
// slash so the path prefix below is exact.
|
||||
func newRoomService(apiURL string, credentials signer) roomService {
|
||||
if strings.HasPrefix(apiURL, "ws") {
|
||||
apiURL = "http" + strings.TrimPrefix(apiURL, "ws")
|
||||
}
|
||||
return roomService{baseURL: strings.TrimSuffix(apiURL, "/"), signer: credentials}
|
||||
}
|
||||
|
||||
func (service roomService) call(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
grant videoGrant,
|
||||
request any,
|
||||
response any,
|
||||
) error {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := service.signer.serviceToken(grant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
service.baseURL+twirpPathPrefix+method, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
httpResponse, err := http.DefaultClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer httpResponse.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(httpResponse.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
|
||||
return newTwirpError(httpResponse, payload)
|
||||
}
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(payload, response)
|
||||
}
|
||||
|
||||
// newTwirpError decodes {"code","msg"} when the reply claims JSON. LiveKit's
|
||||
// own 401 is plain text or {"Error":...}, which keeps code empty and so is not
|
||||
// a not-found, exactly as the TypeScript SDK behaved.
|
||||
func newTwirpError(response *http.Response, payload []byte) *twirpError {
|
||||
failure := &twirpError{status: response.StatusCode, message: "Unknown internal error"}
|
||||
if strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if json.Unmarshal(payload, &body) == nil && body.Code != "" {
|
||||
failure.code = body.Code
|
||||
failure.message = body.Msg
|
||||
}
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
func (service roomService) listRooms(ctx context.Context, names []string) ([]roomRecord, error) {
|
||||
// protobuf-es omits an empty repeated field, so an unfiltered list sends {}.
|
||||
request := struct {
|
||||
Names []string `json:"names,omitempty"`
|
||||
}{Names: names}
|
||||
var response struct {
|
||||
Rooms []roomRecord `json:"rooms"`
|
||||
}
|
||||
if err := service.call(ctx, "ListRooms",
|
||||
videoGrant{RoomList: true}, request, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Rooms, nil
|
||||
}
|
||||
|
||||
func (service roomService) createRoom(ctx context.Context, room string, maxParticipants int) error {
|
||||
request := struct {
|
||||
Name string `json:"name"`
|
||||
MaxParticipants int `json:"maxParticipants"`
|
||||
}{Name: room, MaxParticipants: maxParticipants}
|
||||
// The TypeScript discarded the returned Room record.
|
||||
return service.call(ctx, "CreateRoom", videoGrant{RoomCreate: true}, request, nil)
|
||||
}
|
||||
|
||||
func (service roomService) deleteRoom(ctx context.Context, room string) error {
|
||||
request := struct {
|
||||
Room string `json:"room"`
|
||||
}{Room: room}
|
||||
return service.call(ctx, "DeleteRoom", videoGrant{RoomCreate: true}, request, nil)
|
||||
}
|
||||
|
||||
func (service roomService) listParticipants(ctx context.Context, room string) ([]participantRecord, error) {
|
||||
request := struct {
|
||||
Room string `json:"room"`
|
||||
}{Room: room}
|
||||
var response struct {
|
||||
Participants []participantRecord `json:"participants"`
|
||||
}
|
||||
if err := service.call(ctx, "ListParticipants",
|
||||
videoGrant{RoomAdmin: true, Room: room}, request, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Participants, nil
|
||||
}
|
||||
|
||||
func (service roomService) removeParticipant(ctx context.Context, room string, identity string) error {
|
||||
// revokeTokenTs stays absent, as the TypeScript passed undefined.
|
||||
request := struct {
|
||||
Room string `json:"room"`
|
||||
Identity string `json:"identity"`
|
||||
}{Room: room, Identity: identity}
|
||||
return service.call(ctx, "RemoveParticipant",
|
||||
videoGrant{RoomAdmin: true, Room: room}, request, nil)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package sfu
|
||||
|
||||
// The Twirp wire contract recorded in evidence/livekit.md section 3: request
|
||||
// path, method, headers, body and per-call service-token grant for each of the
|
||||
// five RPCs, plus the exact not-found rule.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type capturedCall struct {
|
||||
path string
|
||||
method string
|
||||
contentType string
|
||||
authorized string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func captureLiveKit(
|
||||
t *testing.T,
|
||||
reply func(method string) (int, string, string),
|
||||
) (*[]capturedCall, string) {
|
||||
t.Helper()
|
||||
var calls []capturedCall
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&body)
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
calls = append(calls, capturedCall{
|
||||
path: request.URL.Path,
|
||||
method: request.Method,
|
||||
contentType: request.Header.Get("Content-Type"),
|
||||
authorized: request.Header.Get("Authorization"),
|
||||
body: body,
|
||||
})
|
||||
name := request.URL.Path[strings.LastIndexByte(request.URL.Path, '/')+1:]
|
||||
status, contentType, payload := reply(name)
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write([]byte(payload))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return &calls, server.URL
|
||||
}
|
||||
|
||||
func TestRoomServiceRequestShape(t *testing.T) {
|
||||
const room = "screener-v1.42.a_1234567.b_1234567"
|
||||
calls, apiURL := captureLiveKit(t, func(method string) (int, string, string) {
|
||||
switch method {
|
||||
case "ListRooms":
|
||||
return 200, "application/json", `{"rooms":[{"sid":"RM_x","name":"` + room + `","empty_timeout":0}]}`
|
||||
case "ListParticipants":
|
||||
return 200, "application/json", `{"participants":[{"sid":"PA_x","identity":"host","state":"ACTIVE"}]}`
|
||||
default:
|
||||
return 200, "application/json", `{}`
|
||||
}
|
||||
})
|
||||
// The ws->http rewrite of TwirpRpc, exercised on the way in.
|
||||
service := newRoomService(strings.Replace(apiURL, "http", "ws", 1),
|
||||
newSigner(testAPIKey, testAPISecret))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := service.listRooms(ctx, nil); err != nil {
|
||||
t.Fatalf("listRooms: %v", err)
|
||||
}
|
||||
if _, err := service.listRooms(ctx, []string{room}); err != nil {
|
||||
t.Fatalf("filtered listRooms: %v", err)
|
||||
}
|
||||
if err := service.createRoom(ctx, room, 9); err != nil {
|
||||
t.Fatalf("createRoom: %v", err)
|
||||
}
|
||||
if err := service.deleteRoom(ctx, room); err != nil {
|
||||
t.Fatalf("deleteRoom: %v", err)
|
||||
}
|
||||
participants, err := service.listParticipants(ctx, room)
|
||||
if err != nil {
|
||||
t.Fatalf("listParticipants: %v", err)
|
||||
}
|
||||
if len(participants) != 1 || participants[0].Identity != "host" {
|
||||
t.Fatalf("participants = %v", participants)
|
||||
}
|
||||
if err := service.removeParticipant(ctx, room, "viewer:viewer_peer_12345678"); err != nil {
|
||||
t.Fatalf("removeParticipant: %v", err)
|
||||
}
|
||||
|
||||
wanted := []struct {
|
||||
path string
|
||||
body map[string]any
|
||||
grant map[string]any
|
||||
}{
|
||||
{"ListRooms", map[string]any{}, map[string]any{"roomList": true}},
|
||||
{"ListRooms", map[string]any{"names": []any{room}}, map[string]any{"roomList": true}},
|
||||
{"CreateRoom", map[string]any{"name": room, "maxParticipants": float64(9)},
|
||||
map[string]any{"roomCreate": true}},
|
||||
{"DeleteRoom", map[string]any{"room": room}, map[string]any{"roomCreate": true}},
|
||||
{"ListParticipants", map[string]any{"room": room},
|
||||
map[string]any{"roomAdmin": true, "room": room}},
|
||||
{"RemoveParticipant", map[string]any{"room": room, "identity": "viewer:viewer_peer_12345678"},
|
||||
map[string]any{"roomAdmin": true, "room": room}},
|
||||
}
|
||||
if len(*calls) != len(wanted) {
|
||||
t.Fatalf("calls = %d, want %d", len(*calls), len(wanted))
|
||||
}
|
||||
for index, call := range *calls {
|
||||
want := wanted[index]
|
||||
if call.path != "/twirp/livekit.RoomService/"+want.path {
|
||||
t.Fatalf("call %d path = %s", index, call.path)
|
||||
}
|
||||
if call.method != http.MethodPost {
|
||||
t.Fatalf("call %d method = %s", index, call.method)
|
||||
}
|
||||
if call.contentType != "application/json;charset=UTF-8" {
|
||||
t.Fatalf("call %d content-type = %q", index, call.contentType)
|
||||
}
|
||||
if !strings.HasPrefix(call.authorized, "Bearer ") {
|
||||
t.Fatalf("call %d authorization = %q", index, call.authorized)
|
||||
}
|
||||
if !reflect.DeepEqual(call.body, want.body) {
|
||||
t.Fatalf("call %d body = %v, want %v", index, call.body, want.body)
|
||||
}
|
||||
|
||||
claims := decodeSegment(t,
|
||||
strings.Split(strings.TrimPrefix(call.authorized, "Bearer "), ".")[1])
|
||||
if _, present := claims["sub"]; present {
|
||||
t.Fatalf("call %d service token must carry no sub", index)
|
||||
}
|
||||
if claims["iss"] != testAPIKey {
|
||||
t.Fatalf("call %d iss = %v", index, claims["iss"])
|
||||
}
|
||||
if lifetime := claims["exp"].(float64) - claims["nbf"].(float64); lifetime != 600 {
|
||||
t.Fatalf("call %d service token exp - nbf = %v, want 600", index, lifetime)
|
||||
}
|
||||
grant, _ := claims["video"].(map[string]any)
|
||||
if !reflect.DeepEqual(grant, want.grant) {
|
||||
t.Fatalf("call %d grant = %v, want %v", index, grant, want.grant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomServiceNotFoundRule pins isNotFound: HTTP 404 or code "not_found",
|
||||
// nothing else. Both arms of the TypeScript `||` are reachable.
|
||||
func TestRoomServiceNotFoundRule(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
status int
|
||||
contentType string
|
||||
payload string
|
||||
notFound bool
|
||||
}{
|
||||
"twirp not found": {404, "application/json", `{"code":"not_found","msg":"missing"}`, true},
|
||||
"status only": {404, "text/plain", "Not Found", true},
|
||||
"code only": {500, "application/json", `{"code":"not_found","msg":"missing"}`, true},
|
||||
"already exists": {409, "application/json", `{"code":"already_exists","msg":"duplicate"}`, false},
|
||||
"internal": {500, "application/json", `{"code":"internal","msg":"boom"}`, false},
|
||||
"unauthenticated": {401, "application/json", `{"Error":"invalid API key"}`, false},
|
||||
"plain text error": {503, "text/plain", "unavailable", false},
|
||||
} {
|
||||
_, apiURL := captureLiveKit(t, func(string) (int, string, string) {
|
||||
return testCase.status, testCase.contentType, testCase.payload
|
||||
})
|
||||
service := newRoomService(apiURL, newSigner(testAPIKey, testAPISecret))
|
||||
err := service.deleteRoom(context.Background(), "screener-v1.42.a_1234567.b_1234567")
|
||||
if err == nil {
|
||||
t.Fatalf("%s: want an error", name)
|
||||
}
|
||||
if isNotFound(err) != testCase.notFound {
|
||||
t.Fatalf("%s: isNotFound = %v, want %v (%v)", name, !testCase.notFound, testCase.notFound, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Package sfutest holds the SFU test double the TypeScript suite kept in
|
||||
// tests/fake-sfu-room-control.ts, so the signaling, router and app tests can
|
||||
// drive the LiveKit fallback without a LiveKit.
|
||||
package sfutest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
||||
)
|
||||
|
||||
// FakeRoomControl is FakeSfuRoomControl. Every field is guarded: unlike the
|
||||
// single-threaded TypeScript, the router calls it from goroutines while the
|
||||
// test asserts. A barrier is a channel the test closes to release the call;
|
||||
// a nil barrier does not block, which is the TypeScript `await undefined`.
|
||||
type FakeRoomControl struct {
|
||||
mu sync.Mutex
|
||||
// Ordering site: initialize reports the room names in insertion order.
|
||||
rooms ordered.Map[string, map[string]struct{}]
|
||||
created []sfu.ResourceFence
|
||||
deleted []sfu.ResourceFence
|
||||
subscriptionDrainAttempts []sfu.SubscriptionFence
|
||||
drainedSubscriptions []sfu.SubscriptionFence
|
||||
startupDeletedRoomNames []string
|
||||
initializeCalls int
|
||||
initializeBarrier chan struct{}
|
||||
initializeError error
|
||||
createBarrier chan struct{}
|
||||
subscriptionDrainBarrier chan struct{}
|
||||
failDelete bool
|
||||
failHostCheck bool
|
||||
}
|
||||
|
||||
var _ sfu.RoomControl = (*FakeRoomControl)(nil)
|
||||
|
||||
// New returns an empty fake.
|
||||
func New() *FakeRoomControl { return &FakeRoomControl{} }
|
||||
|
||||
// Initialize records the call, then clears every room.
|
||||
func (fake *FakeRoomControl) Initialize(ctx context.Context) error {
|
||||
fake.mu.Lock()
|
||||
fake.initializeCalls++
|
||||
barrier := fake.initializeBarrier
|
||||
fake.mu.Unlock()
|
||||
|
||||
if err := wait(ctx, barrier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.initializeError != nil {
|
||||
return fake.initializeError
|
||||
}
|
||||
fake.startupDeletedRoomNames = append(fake.startupDeletedRoomNames, fake.rooms.Keys()...)
|
||||
fake.rooms.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRoom refuses a room name that already exists.
|
||||
func (fake *FakeRoomControl) CreateRoom(ctx context.Context, fence sfu.ResourceFence) error {
|
||||
fake.mu.Lock()
|
||||
barrier := fake.createBarrier
|
||||
fake.mu.Unlock()
|
||||
|
||||
if err := wait(ctx, barrier); err != nil {
|
||||
return err
|
||||
}
|
||||
roomName := sfu.ManagedRoomName(fence)
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.rooms.Has(roomName) {
|
||||
return errors.New("room already exists")
|
||||
}
|
||||
fake.rooms.Set(roomName, map[string]struct{}{})
|
||||
fake.created = append(fake.created, fence)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRoom removes the room, whether or not it existed.
|
||||
func (fake *FakeRoomControl) DeleteRoom(ctx context.Context, fence sfu.ResourceFence) error {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.failDelete {
|
||||
return errors.New("room deletion failed")
|
||||
}
|
||||
fake.rooms.Delete(sfu.ManagedRoomName(fence))
|
||||
fake.deleted = append(fake.deleted, fence)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DrainSubscription records the attempt before waiting, then removes the viewer.
|
||||
func (fake *FakeRoomControl) DrainSubscription(ctx context.Context, fence sfu.SubscriptionFence) error {
|
||||
fake.mu.Lock()
|
||||
fake.subscriptionDrainAttempts = append(fake.subscriptionDrainAttempts, fence)
|
||||
barrier := fake.subscriptionDrainBarrier
|
||||
fake.mu.Unlock()
|
||||
|
||||
if err := wait(ctx, barrier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if participants, ok := fake.rooms.Get(sfu.ManagedRoomName(fence.ResourceFence)); ok {
|
||||
delete(participants, "viewer:"+fence.ViewerPeerID)
|
||||
}
|
||||
fake.drainedSubscriptions = append(fake.drainedSubscriptions, fence)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HostParticipantExists reports whether "host" joined the fenced room.
|
||||
func (fake *FakeRoomControl) HostParticipantExists(
|
||||
_ context.Context,
|
||||
fence sfu.ResourceFence,
|
||||
) (bool, error) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.failHostCheck {
|
||||
return false, errors.New("host check failed")
|
||||
}
|
||||
participants, ok := fake.rooms.Get(sfu.ManagedRoomName(fence))
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
_, joined := participants["host"]
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// SeedRoom creates the fenced room with the given identities present.
|
||||
func (fake *FakeRoomControl) SeedRoom(fence sfu.ResourceFence, identities ...string) {
|
||||
participants := make(map[string]struct{}, len(identities))
|
||||
for _, identity := range identities {
|
||||
participants[identity] = struct{}{}
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.rooms.Set(sfu.ManagedRoomName(fence), participants)
|
||||
}
|
||||
|
||||
// CanJoin reports whether the room name exists.
|
||||
func (fake *FakeRoomControl) CanJoin(roomName string) bool {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return fake.rooms.Has(roomName)
|
||||
}
|
||||
|
||||
// RoomCount is rooms.size.
|
||||
func (fake *FakeRoomControl) RoomCount() int {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return fake.rooms.Len()
|
||||
}
|
||||
|
||||
// Participants lists the identities in a room, sorted for a stable assertion.
|
||||
func (fake *FakeRoomControl) Participants(roomName string) []string {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
participants, ok := fake.rooms.Get(roomName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
identities := make([]string, 0, len(participants))
|
||||
for identity := range participants {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
sort.Strings(identities)
|
||||
return identities
|
||||
}
|
||||
|
||||
// InitializeCalls is initializeCalls.
|
||||
func (fake *FakeRoomControl) InitializeCalls() int {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return fake.initializeCalls
|
||||
}
|
||||
|
||||
// StartupDeletedRoomNames is startupDeletedRoomNames.
|
||||
func (fake *FakeRoomControl) StartupDeletedRoomNames() []string {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return append([]string(nil), fake.startupDeletedRoomNames...)
|
||||
}
|
||||
|
||||
// Created is created.
|
||||
func (fake *FakeRoomControl) Created() []sfu.ResourceFence {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return append([]sfu.ResourceFence(nil), fake.created...)
|
||||
}
|
||||
|
||||
// Deleted is deleted.
|
||||
func (fake *FakeRoomControl) Deleted() []sfu.ResourceFence {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return append([]sfu.ResourceFence(nil), fake.deleted...)
|
||||
}
|
||||
|
||||
// SubscriptionDrainAttempts is subscriptionDrainAttempts.
|
||||
func (fake *FakeRoomControl) SubscriptionDrainAttempts() []sfu.SubscriptionFence {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return append([]sfu.SubscriptionFence(nil), fake.subscriptionDrainAttempts...)
|
||||
}
|
||||
|
||||
// DrainedSubscriptions is drainedSubscriptions.
|
||||
func (fake *FakeRoomControl) DrainedSubscriptions() []sfu.SubscriptionFence {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
return append([]sfu.SubscriptionFence(nil), fake.drainedSubscriptions...)
|
||||
}
|
||||
|
||||
// SetInitializeBarrier blocks Initialize until barrier is closed.
|
||||
func (fake *FakeRoomControl) SetInitializeBarrier(barrier chan struct{}) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.initializeBarrier = barrier
|
||||
}
|
||||
|
||||
// SetInitializeError makes Initialize fail after its barrier.
|
||||
func (fake *FakeRoomControl) SetInitializeError(err error) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.initializeError = err
|
||||
}
|
||||
|
||||
// SetCreateBarrier blocks CreateRoom until barrier is closed.
|
||||
func (fake *FakeRoomControl) SetCreateBarrier(barrier chan struct{}) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.createBarrier = barrier
|
||||
}
|
||||
|
||||
// SetSubscriptionDrainBarrier blocks DrainSubscription until barrier is closed.
|
||||
func (fake *FakeRoomControl) SetSubscriptionDrainBarrier(barrier chan struct{}) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.subscriptionDrainBarrier = barrier
|
||||
}
|
||||
|
||||
// SetFailDelete makes DeleteRoom fail.
|
||||
func (fake *FakeRoomControl) SetFailDelete(fail bool) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.failDelete = fail
|
||||
}
|
||||
|
||||
// SetFailHostCheck makes HostParticipantExists fail.
|
||||
func (fake *FakeRoomControl) SetFailHostCheck(fail bool) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
fake.failHostCheck = fail
|
||||
}
|
||||
|
||||
// wait is `await barrier` with the Go escape hatch a cancelled context needs.
|
||||
func wait(ctx context.Context, barrier chan struct{}) error {
|
||||
if barrier == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-barrier:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package sfutest
|
||||
|
||||
// The behaviour tests/fake-sfu-room-control.ts guarantees to the scenarios that
|
||||
// use it, so a signaling or app test can rely on it the same way.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
||||
)
|
||||
|
||||
func fence(publicationGeneration string) sfu.ResourceFence {
|
||||
return sfu.ResourceFence{
|
||||
RoomID: "42",
|
||||
ShareGeneration: "share_generation_12345678",
|
||||
PublicationGeneration: publicationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeInitializeDrainsSeededRooms(t *testing.T) {
|
||||
fake := New()
|
||||
first := fence("publication_generation_12345678")
|
||||
second := fence("publication_generation_87654321")
|
||||
fake.SeedRoom(first, "host")
|
||||
fake.SeedRoom(second)
|
||||
barrier := make(chan struct{})
|
||||
fake.SetInitializeBarrier(barrier)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- fake.Initialize(context.Background()) }()
|
||||
// The call is counted before the barrier, which is what a startup test that
|
||||
// asserts "initialize was entered" observes.
|
||||
waitFor(t, "the initialize call", func() bool { return fake.InitializeCalls() == 1 })
|
||||
if fake.RoomCount() != 2 {
|
||||
t.Fatalf("rooms = %d, want the seeded pair", fake.RoomCount())
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if names := fake.StartupDeletedRoomNames(); !slices.Equal(names,
|
||||
[]string{sfu.ManagedRoomName(first), sfu.ManagedRoomName(second)}) {
|
||||
t.Fatalf("startup deleted = %v, want both rooms in seeding order", names)
|
||||
}
|
||||
if fake.RoomCount() != 0 {
|
||||
t.Fatalf("rooms = %d, want none", fake.RoomCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeInitializeReportsInjectedFailure(t *testing.T) {
|
||||
fake := New()
|
||||
failure := errors.New("startup reconciliation failed")
|
||||
fake.SetInitializeError(failure)
|
||||
fake.SeedRoom(fence("publication_generation_12345678"))
|
||||
|
||||
if err := fake.Initialize(context.Background()); !errors.Is(err, failure) {
|
||||
t.Fatalf("Initialize error = %v, want %v", err, failure)
|
||||
}
|
||||
if fake.RoomCount() != 1 {
|
||||
t.Fatal("a failed initialize must not clear the rooms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRecordsRoomLifecycle(t *testing.T) {
|
||||
fake := New()
|
||||
ctx := context.Background()
|
||||
current := fence("publication_generation_12345678")
|
||||
roomName := sfu.ManagedRoomName(current)
|
||||
|
||||
if err := fake.CreateRoom(ctx, current); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
if err := fake.CreateRoom(ctx, current); err == nil {
|
||||
t.Fatal("a duplicate room must be refused")
|
||||
}
|
||||
if !fake.CanJoin(roomName) {
|
||||
t.Fatal("the created room must accept a join")
|
||||
}
|
||||
if created := fake.Created(); !slices.Equal(created, []sfu.ResourceFence{current}) {
|
||||
t.Fatalf("created = %v", created)
|
||||
}
|
||||
|
||||
fake.SetFailDelete(true)
|
||||
if err := fake.DeleteRoom(ctx, current); err == nil {
|
||||
t.Fatal("the injected delete failure must surface")
|
||||
}
|
||||
if !fake.CanJoin(roomName) || len(fake.Deleted()) != 0 {
|
||||
t.Fatal("a failed delete must change nothing")
|
||||
}
|
||||
fake.SetFailDelete(false)
|
||||
if err := fake.DeleteRoom(ctx, current); err != nil {
|
||||
t.Fatalf("DeleteRoom: %v", err)
|
||||
}
|
||||
if fake.CanJoin(roomName) {
|
||||
t.Fatal("the deleted room must not accept a join")
|
||||
}
|
||||
if deleted := fake.Deleted(); !slices.Equal(deleted, []sfu.ResourceFence{current}) {
|
||||
t.Fatalf("deleted = %v", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDrainsOnlyTheExactViewer(t *testing.T) {
|
||||
fake := New()
|
||||
ctx := context.Background()
|
||||
current := fence("publication_generation_12345678")
|
||||
roomName := sfu.ManagedRoomName(current)
|
||||
fake.SeedRoom(current, "host", "viewer:viewer_peer_12345678", "viewer:other_peer_12345678")
|
||||
barrier := make(chan struct{})
|
||||
fake.SetSubscriptionDrainBarrier(barrier)
|
||||
|
||||
subscription := sfu.SubscriptionFence{
|
||||
ResourceFence: current,
|
||||
ViewerPeerID: "viewer_peer_12345678",
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- fake.DrainSubscription(ctx, subscription) }()
|
||||
// The attempt is recorded before the barrier; the removal is not.
|
||||
waitFor(t, "the drain attempt", func() bool {
|
||||
return len(fake.SubscriptionDrainAttempts()) == 1
|
||||
})
|
||||
if len(fake.DrainedSubscriptions()) != 0 {
|
||||
t.Fatal("the drain must not complete before its barrier")
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("DrainSubscription: %v", err)
|
||||
}
|
||||
if participants := fake.Participants(roomName); !slices.Equal(participants,
|
||||
[]string{"host", "viewer:other_peer_12345678"}) {
|
||||
t.Fatalf("participants = %v", participants)
|
||||
}
|
||||
if drained := fake.DrainedSubscriptions(); !slices.Equal(drained,
|
||||
[]sfu.SubscriptionFence{subscription}) {
|
||||
t.Fatalf("drained = %v", drained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeHostParticipantExists(t *testing.T) {
|
||||
fake := New()
|
||||
ctx := context.Background()
|
||||
hosted := fence("publication_generation_12345678")
|
||||
empty := fence("publication_generation_87654321")
|
||||
fake.SeedRoom(hosted, "host")
|
||||
fake.SeedRoom(empty, "viewer:viewer_peer_12345678")
|
||||
|
||||
for _, testCase := range []struct {
|
||||
fence sfu.ResourceFence
|
||||
want bool
|
||||
}{{hosted, true}, {empty, false}, {fence("publication_generation_11112222"), false}} {
|
||||
exists, err := fake.HostParticipantExists(ctx, testCase.fence)
|
||||
if err != nil {
|
||||
t.Fatalf("HostParticipantExists: %v", err)
|
||||
}
|
||||
if exists != testCase.want {
|
||||
t.Fatalf("HostParticipantExists(%s) = %v, want %v",
|
||||
testCase.fence.PublicationGeneration, exists, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
fake.SetFailHostCheck(true)
|
||||
if _, err := fake.HostParticipantExists(ctx, hosted); err == nil {
|
||||
t.Fatal("the injected host-check failure must surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeReleasesBlockedCallsOnCancellation(t *testing.T) {
|
||||
fake := New()
|
||||
fake.SetCreateBarrier(make(chan struct{}))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := fake.CreateRoom(ctx, fence("publication_generation_12345678")); err == nil {
|
||||
t.Fatal("a cancelled context must release the barrier")
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor is vitest's vi.waitFor: poll until the condition holds.
|
||||
func waitFor(t *testing.T, description string, condition func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package sfu
|
||||
|
||||
// Ported from src/server/livekit-token.ts. The AccessToken of
|
||||
// livekit-server-sdk is replaced by the HS256 JWT it produced; the claim set is
|
||||
// the one recorded in evidence/livekit.md section 2.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
// tokenTTLSeconds and serviceTokenTTLSeconds are the AccessToken `ttl`
|
||||
// values of src/server/livekit-token.ts and the SDK's ServiceBase: a
|
||||
// participant token only has to survive the join it was issued for, and a
|
||||
// service token only the one RoomService call it authorises.
|
||||
tokenTTLSeconds = 5 * 60
|
||||
serviceTokenTTLSeconds = 10 * 60
|
||||
// minAPISecretBytes is the TypeScript Buffer.byteLength(secret) >= 32 guard,
|
||||
// which is len() in Go because both count UTF-8 bytes.
|
||||
minAPISecretBytes = 32
|
||||
// TrackSource enum strings, from livekit-server-sdk grants.ts
|
||||
// trackSourceToString().
|
||||
trackSourceScreenShare = "screen_share"
|
||||
trackSourceScreenShareAudio = "screen_share_audio"
|
||||
)
|
||||
|
||||
// videoGrant is the subset of the SDK's VideoGrant this server sets. The four
|
||||
// capability flags are *bool because absent and false differ to LiveKit:
|
||||
// GetCanPublish() answers true for an absent flag, so a viewer's
|
||||
// "canPublish": false must be emitted, not omitted.
|
||||
type videoGrant struct {
|
||||
RoomCreate bool `json:"roomCreate,omitempty"`
|
||||
RoomJoin bool `json:"roomJoin,omitempty"`
|
||||
RoomList bool `json:"roomList,omitempty"`
|
||||
RoomAdmin bool `json:"roomAdmin,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
CanPublish *bool `json:"canPublish,omitempty"`
|
||||
CanPublishSources []string `json:"canPublishSources,omitempty"`
|
||||
CanSubscribe *bool `json:"canSubscribe,omitempty"`
|
||||
CanPublishData *bool `json:"canPublishData,omitempty"`
|
||||
CanUpdateOwnMetadata *bool `json:"canUpdateOwnMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// tokenClaims is the registered claim set jose emitted for AccessToken: no
|
||||
// `iat`, no duplicate `identity`, and no `sub` on a service token.
|
||||
type tokenClaims struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Nbf int64 `json:"nbf"`
|
||||
Exp int64 `json:"exp"`
|
||||
Video videoGrant `json:"video"`
|
||||
}
|
||||
|
||||
// signer holds the API credentials shared by the participant token issuer and
|
||||
// the RoomService client.
|
||||
type signer struct {
|
||||
apiKey string
|
||||
apiSecret []byte
|
||||
}
|
||||
|
||||
func newSigner(apiKey string, apiSecret string) signer {
|
||||
return signer{apiKey: apiKey, apiSecret: []byte(apiSecret)}
|
||||
}
|
||||
|
||||
// sign emits the compact JWS. jose's SignJWT with {alg:"HS256"} writes exactly
|
||||
// {"alg":"HS256"} as the protected header: no typ, no kid.
|
||||
func (s signer) sign(claims tokenClaims) (string, error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
header := []byte(`{"alg":"HS256"}`)
|
||||
encoding := base64.RawURLEncoding
|
||||
signing := make([]byte, 0,
|
||||
encoding.EncodedLen(len(header))+1+encoding.EncodedLen(len(payload)))
|
||||
signing = encoding.AppendEncode(signing, header)
|
||||
signing = append(signing, '.')
|
||||
signing = encoding.AppendEncode(signing, payload)
|
||||
mac := hmac.New(sha256.New, s.apiSecret)
|
||||
mac.Write(signing)
|
||||
token := append(signing, '.')
|
||||
token = encoding.AppendEncode(token, mac.Sum(nil))
|
||||
return string(token), nil
|
||||
}
|
||||
|
||||
// serviceToken reproduces ServiceBase.authHeader: a 10 minute admin token
|
||||
// carrying one video grant and no subject.
|
||||
func (s signer) serviceToken(grant videoGrant) (string, error) {
|
||||
notBefore := time.Now().Unix()
|
||||
return s.sign(tokenClaims{
|
||||
Iss: s.apiKey,
|
||||
Nbf: notBefore,
|
||||
Exp: notBefore + serviceTokenTTLSeconds,
|
||||
Video: grant,
|
||||
})
|
||||
}
|
||||
|
||||
// TokenRequest is SfuTokenRequest.
|
||||
type TokenRequest struct {
|
||||
RoomID string
|
||||
Role protocol.Role
|
||||
PeerID string
|
||||
ShareGeneration string
|
||||
PublicationGeneration string
|
||||
}
|
||||
|
||||
// TokenIssuer is SfuTokenIssuer. Issuing is local HMAC work, so unlike
|
||||
// RoomControl it takes no context.
|
||||
type TokenIssuer interface {
|
||||
IssueToken(request TokenRequest) (string, error)
|
||||
}
|
||||
|
||||
// LiveKitTokenIssuerOptions is LiveKitTokenIssuerOptions.
|
||||
type LiveKitTokenIssuerOptions struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
MaxViewersPerRoom int
|
||||
}
|
||||
|
||||
// LiveKitTokenIssuer is LiveKitTokenIssuer.
|
||||
type LiveKitTokenIssuer struct {
|
||||
signer signer
|
||||
}
|
||||
|
||||
var _ TokenIssuer = (*LiveKitTokenIssuer)(nil)
|
||||
|
||||
// NewLiveKitTokenIssuer mirrors the LiveKitTokenIssuer constructor.
|
||||
func NewLiveKitTokenIssuer(options LiveKitTokenIssuerOptions) (*LiveKitTokenIssuer, error) {
|
||||
if options.APIKey == "" || len(options.APISecret) < minAPISecretBytes {
|
||||
return nil, errors.New("LiveKit API credentials are invalid")
|
||||
}
|
||||
if err := validateViewerLimit(options.MaxViewersPerRoom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LiveKitTokenIssuer{signer: newSigner(options.APIKey, options.APISecret)}, nil
|
||||
}
|
||||
|
||||
// IssueToken ports issueToken.
|
||||
func (issuer *LiveKitTokenIssuer) IssueToken(request TokenRequest) (string, error) {
|
||||
if !roomIDPattern.MatchString(request.RoomID) {
|
||||
return "", errors.New("LiveKit room ID is invalid")
|
||||
}
|
||||
if !protocol.ValidOpaqueID(request.PeerID) ||
|
||||
!protocol.ValidOpaqueID(request.ShareGeneration) ||
|
||||
!protocol.ValidOpaqueID(request.PublicationGeneration) {
|
||||
return "", errors.New("LiveKit participant identity is invalid")
|
||||
}
|
||||
|
||||
isHost := request.Role == protocol.RoleHost
|
||||
// The validation above is a superset of managedRoomName's, so this cannot
|
||||
// fail; the TypeScript relied on the same ordering.
|
||||
room := ManagedRoomName(ResourceFence{
|
||||
RoomID: request.RoomID,
|
||||
ShareGeneration: request.ShareGeneration,
|
||||
PublicationGeneration: request.PublicationGeneration,
|
||||
})
|
||||
identity := "host"
|
||||
if !isHost {
|
||||
identity = "viewer:" + request.PeerID
|
||||
}
|
||||
grant := videoGrant{
|
||||
RoomJoin: true,
|
||||
Room: room,
|
||||
CanPublish: boolPointer(isHost),
|
||||
CanSubscribe: boolPointer(!isHost),
|
||||
CanPublishData: boolPointer(false),
|
||||
CanUpdateOwnMetadata: boolPointer(false),
|
||||
}
|
||||
if isHost {
|
||||
grant.CanPublishSources = []string{
|
||||
trackSourceScreenShare,
|
||||
trackSourceScreenShareAudio,
|
||||
}
|
||||
}
|
||||
// One clock read: jose called new Date() once per setter, so across a
|
||||
// second boundary its exp - nbf could be 299. Here it is always 300.
|
||||
notBefore := time.Now().Unix()
|
||||
return issuer.signer.sign(tokenClaims{
|
||||
Iss: issuer.signer.apiKey,
|
||||
Sub: identity,
|
||||
Nbf: notBefore,
|
||||
Exp: notBefore + tokenTTLSeconds,
|
||||
Video: grant,
|
||||
})
|
||||
}
|
||||
|
||||
// validateViewerLimit is the shared viewer-limit guard of both LiveKit
|
||||
// constructors. Number.isSafeInteger is implicit in Go's int.
|
||||
func validateViewerLimit(maxViewersPerRoom int) error {
|
||||
if maxViewersPerRoom < 1 || maxViewersPerRoom > protocol.MaxViewersPerRoomLimit {
|
||||
return errors.New("LiveKit viewer limit is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
@@ -0,0 +1,225 @@
|
||||
package sfu
|
||||
|
||||
// Ported from tests/livekit-token.test.ts. The TypeScript verified with the
|
||||
// SDK's TokenVerifier; here the JWT is split, the HS256 signature recomputed
|
||||
// and the payload compared to the claim set recorded in evidence/livekit.md
|
||||
// section 2.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testAPIKey = "test-api-key"
|
||||
// Placeholder credential copied from tests/livekit-token.test.ts.
|
||||
testAPISecret = "ssssssssssssssssssssssssssssssss"
|
||||
testShareGeneration = "share_generation_12345678"
|
||||
testPublicationGeneration = "publication_12345678"
|
||||
)
|
||||
|
||||
// decodeToken checks the signature and returns the header and payload claims.
|
||||
func decodeToken(t *testing.T, token string) (map[string]any, map[string]any) {
|
||||
t.Helper()
|
||||
segments := strings.Split(token, ".")
|
||||
if len(segments) != 3 {
|
||||
t.Fatalf("token has %d segments, want 3", len(segments))
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(testAPISecret))
|
||||
mac.Write([]byte(segments[0] + "." + segments[1]))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if segments[2] != want {
|
||||
t.Fatal("HS256 signature does not verify with the API secret")
|
||||
}
|
||||
return decodeSegment(t, segments[0]), decodeSegment(t, segments[1])
|
||||
}
|
||||
|
||||
func decodeSegment(t *testing.T, segment string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := base64.RawURLEncoding.DecodeString(segment)
|
||||
if err != nil {
|
||||
t.Fatalf("base64: %v", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func testIssuer(t *testing.T, maxViewersPerRoom int) *LiveKitTokenIssuer {
|
||||
t.Helper()
|
||||
issuer, err := NewLiveKitTokenIssuer(LiveKitTokenIssuerOptions{
|
||||
APIKey: testAPIKey,
|
||||
APISecret: testAPISecret,
|
||||
MaxViewersPerRoom: maxViewersPerRoom,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitTokenIssuer: %v", err)
|
||||
}
|
||||
return issuer
|
||||
}
|
||||
|
||||
func issue(t *testing.T, issuer *LiveKitTokenIssuer, request TokenRequest) (map[string]any, map[string]any) {
|
||||
t.Helper()
|
||||
token, err := issuer.IssueToken(request)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
return decodeToken(t, token)
|
||||
}
|
||||
|
||||
func TestTokenIssuesGenerationBoundHostToken(t *testing.T) {
|
||||
before := time.Now().Unix()
|
||||
header, claims := issue(t, testIssuer(t, 8), TokenRequest{
|
||||
RoomID: "42",
|
||||
Role: "host",
|
||||
PeerID: "host_peer_12345678",
|
||||
ShareGeneration: testShareGeneration,
|
||||
PublicationGeneration: testPublicationGeneration,
|
||||
})
|
||||
after := time.Now().Unix()
|
||||
|
||||
// jose emitted exactly {"alg":"HS256"}: no typ, no kid.
|
||||
if !reflect.DeepEqual(header, map[string]any{"alg": "HS256"}) {
|
||||
t.Fatalf("header = %v", header)
|
||||
}
|
||||
if claims["iss"] != testAPIKey || claims["sub"] != "host" {
|
||||
t.Fatalf("iss/sub = %v/%v", claims["iss"], claims["sub"])
|
||||
}
|
||||
notBefore, expires := claims["nbf"].(float64), claims["exp"].(float64)
|
||||
if expires-notBefore != 5*60 {
|
||||
t.Fatalf("exp - nbf = %v, want 300", expires-notBefore)
|
||||
}
|
||||
if notBefore < float64(before) || notBefore > float64(after) {
|
||||
t.Fatalf("nbf = %v, want it inside [%d,%d]", notBefore, before, after)
|
||||
}
|
||||
if _, present := claims["iat"]; present {
|
||||
t.Fatal("iat must be absent")
|
||||
}
|
||||
if _, present := claims["identity"]; present {
|
||||
t.Fatal("identity must be absent")
|
||||
}
|
||||
if _, present := claims["roomConfig"]; present {
|
||||
t.Fatal("roomConfig must be absent")
|
||||
}
|
||||
want := map[string]any{
|
||||
"roomJoin": true,
|
||||
"room": "screener-v1.42." + testShareGeneration + "." + testPublicationGeneration,
|
||||
"canPublish": true,
|
||||
"canSubscribe": false,
|
||||
"canPublishData": false,
|
||||
"canUpdateOwnMetadata": false,
|
||||
"canPublishSources": []any{"screen_share", "screen_share_audio"},
|
||||
}
|
||||
assertVideoGrant(t, claims, want)
|
||||
}
|
||||
|
||||
func TestTokenIssuesSubscribeOnlyViewerToken(t *testing.T) {
|
||||
const viewerPeerID = "viewer_root_12345678"
|
||||
_, claims := issue(t, testIssuer(t, 3), TokenRequest{
|
||||
RoomID: "7",
|
||||
Role: "viewer",
|
||||
PeerID: viewerPeerID,
|
||||
ShareGeneration: testShareGeneration,
|
||||
PublicationGeneration: testPublicationGeneration,
|
||||
})
|
||||
|
||||
if claims["sub"] != "viewer:"+viewerPeerID {
|
||||
t.Fatalf("sub = %v", claims["sub"])
|
||||
}
|
||||
// canPublishSources is dropped entirely for a viewer, and canPublish must
|
||||
// be present as false: an absent flag reads as granted to LiveKit.
|
||||
assertVideoGrant(t, claims, map[string]any{
|
||||
"roomJoin": true,
|
||||
"room": "screener-v1.7." + testShareGeneration + "." + testPublicationGeneration,
|
||||
"canPublish": false,
|
||||
"canSubscribe": true,
|
||||
"canPublishData": false,
|
||||
"canUpdateOwnMetadata": false,
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenIsolatesPublicationGenerations(t *testing.T) {
|
||||
issuer := testIssuer(t, 8)
|
||||
request := TokenRequest{
|
||||
RoomID: "7",
|
||||
Role: "host",
|
||||
PeerID: "host_peer_12345678",
|
||||
ShareGeneration: testShareGeneration,
|
||||
}
|
||||
request.PublicationGeneration = "generation_first_12345678"
|
||||
_, first := issue(t, issuer, request)
|
||||
request.PublicationGeneration = "generation_second_12345678"
|
||||
_, second := issue(t, issuer, request)
|
||||
|
||||
firstRoom := first["video"].(map[string]any)["room"]
|
||||
secondRoom := second["video"].(map[string]any)["room"]
|
||||
if firstRoom == secondRoom {
|
||||
t.Fatalf("both generations share the room %v", firstRoom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRejectsInvalidRequestsAndCredentials(t *testing.T) {
|
||||
issuer := testIssuer(t, 8)
|
||||
base := TokenRequest{
|
||||
RoomID: "42",
|
||||
Role: "host",
|
||||
PeerID: "host_peer_12345678",
|
||||
ShareGeneration: testShareGeneration,
|
||||
PublicationGeneration: testPublicationGeneration,
|
||||
}
|
||||
for name, mutate := range map[string]func(*TokenRequest){
|
||||
"zero room": func(request *TokenRequest) { request.RoomID = "0" },
|
||||
"leading zero": func(request *TokenRequest) { request.RoomID = "042" },
|
||||
"thirteen digits": func(request *TokenRequest) {
|
||||
request.RoomID = "1234567890123"
|
||||
},
|
||||
"empty room": func(request *TokenRequest) { request.RoomID = "" },
|
||||
"short peer": func(request *TokenRequest) { request.PeerID = "short" },
|
||||
"dotted share": func(request *TokenRequest) { request.ShareGeneration = "has.dot.12345678" },
|
||||
"empty publication": func(request *TokenRequest) {
|
||||
request.PublicationGeneration = ""
|
||||
},
|
||||
"unicode peer": func(request *TokenRequest) { request.PeerID = "peer_é_12345678" },
|
||||
} {
|
||||
request := base
|
||||
mutate(&request)
|
||||
if _, err := issuer.IssueToken(request); err == nil {
|
||||
t.Fatalf("%s: want a rejection", name)
|
||||
}
|
||||
}
|
||||
|
||||
for name, options := range map[string]LiveKitTokenIssuerOptions{
|
||||
"empty key": {APIKey: "", APISecret: testAPISecret, MaxViewersPerRoom: 8},
|
||||
"short secret": {APIKey: testAPIKey, APISecret: strings.Repeat("s", 31), MaxViewersPerRoom: 8},
|
||||
"no viewers": {APIKey: testAPIKey, APISecret: testAPISecret, MaxViewersPerRoom: 0},
|
||||
"too many viewers": {
|
||||
APIKey: testAPIKey, APISecret: testAPISecret, MaxViewersPerRoom: 21,
|
||||
},
|
||||
} {
|
||||
if _, err := NewLiveKitTokenIssuer(options); err == nil {
|
||||
t.Fatalf("%s: want a rejection", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertVideoGrant compares the whole claim: an extra or missing key is a
|
||||
// different grant, which is the point of canPublishSources being absent for a
|
||||
// viewer and canPublish being present as false.
|
||||
func assertVideoGrant(t *testing.T, claims map[string]any, want map[string]any) {
|
||||
t.Helper()
|
||||
grant, ok := claims["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video = %v", claims["video"])
|
||||
}
|
||||
if !reflect.DeepEqual(grant, want) {
|
||||
t.Fatalf("video = %v, want %v", grant, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package signal
|
||||
|
||||
// Viewer presence, host status, disconnect handling and room termination of
|
||||
// src/server/signaling.ts (sendViewerPresence 1594, broadcastHostStatus
|
||||
// 1683, handleDisconnect 1397, stopSharing 1466, terminateRoom 1500,
|
||||
// revokeGrantViewers 1016, closeRevokedViewerSession 1697 and the
|
||||
// viewer-keyed map helpers). Every function here runs with mu held.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/room"
|
||||
)
|
||||
|
||||
// viewerKey is the TS `${roomId}:${peerId}` used by the grace timers and the
|
||||
// viewer quality evidence maps (viewerGraceKey and viewerConnectionKey were
|
||||
// the same expression).
|
||||
func viewerKey(roomID, peerID string) string { return roomID + ":" + peerID }
|
||||
|
||||
// sendViewerPresence ports sendViewerPresence (O12): the host entry first
|
||||
// when it has a display name, then viewers in store order; recipients are
|
||||
// the opted-in host first, then opted-in viewers in store order, deduped by
|
||||
// connection (hazard 11).
|
||||
func (s *Server) sendViewerPresence(roomID string) {
|
||||
if _, deferred := s.deferredViewerPresenceRooms[roomID]; deferred {
|
||||
return
|
||||
}
|
||||
|
||||
connectedViewers := s.store.GetConnectedViewers(roomID)
|
||||
viewers := []protocol.ParticipantPresenceEntry{}
|
||||
host, hasHost := s.store.GetConnectedHost(roomID)
|
||||
var hostSession *session
|
||||
var hostState *authenticatedSession
|
||||
if hasHost {
|
||||
hostSession = s.sessionsByID[host.SessionID]
|
||||
if hostSession != nil {
|
||||
hostState = hostSession.authenticated
|
||||
}
|
||||
}
|
||||
if hasHost && hostSession != nil && hostState != nil &&
|
||||
hostState.role == protocol.RoleHost &&
|
||||
hostState.roomID == roomID &&
|
||||
hostState.peerID == host.PeerID &&
|
||||
hostState.displayName != nil {
|
||||
viewers = append(viewers, protocol.HostPresenceEntry(host.PeerID, protocol.DisplayName(*hostState.displayName)))
|
||||
}
|
||||
|
||||
for _, viewer := range connectedViewers {
|
||||
viewerState := s.authenticatedOf(viewer.SessionID)
|
||||
if viewerState == nil ||
|
||||
viewerState.role != protocol.RoleViewer ||
|
||||
viewerState.roomID != roomID ||
|
||||
viewerState.peerID != viewer.PeerID ||
|
||||
viewerState.displayName == nil {
|
||||
continue
|
||||
}
|
||||
upstream := s.router.getViewerRouteUpstream(roomID, viewer.PeerID)
|
||||
activeEdge, hasActiveEdge := s.router.resolveActiveViewerMediaEdge(roomID, viewer.PeerID)
|
||||
mediaReady := hasActiveEdge && upstream.Kind != "none"
|
||||
if mediaReady {
|
||||
if activeEdge.upstream.Kind == "sfu" {
|
||||
mediaReady = upstream.Kind == "sfu"
|
||||
} else {
|
||||
mediaReady = upstream.Kind == "peer" && activeEdge.upstream.PeerID == upstream.PeerID
|
||||
}
|
||||
}
|
||||
viewers = append(viewers, protocol.NewViewerPresenceEntry(
|
||||
viewer.PeerID, protocol.DisplayName(*viewerState.displayName), upstream, mediaReady))
|
||||
}
|
||||
encoded, err := protocol.EncodeServerMessage(protocol.ViewerPresenceMessage{
|
||||
Type: "viewer-presence",
|
||||
Viewers: viewers,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var recipients []*session
|
||||
seen := map[*session]struct{}{}
|
||||
add := func(sess *session) {
|
||||
if _, duplicate := seen[sess]; duplicate {
|
||||
return
|
||||
}
|
||||
seen[sess] = struct{}{}
|
||||
recipients = append(recipients, sess)
|
||||
}
|
||||
if hostSession != nil && hostState != nil && hostState.viewerPresence {
|
||||
add(hostSession)
|
||||
}
|
||||
for _, viewer := range connectedViewers {
|
||||
viewerSession := s.sessionsByID[viewer.SessionID]
|
||||
if viewerSession == nil {
|
||||
continue
|
||||
}
|
||||
viewerState := viewerSession.authenticated
|
||||
if viewerState != nil &&
|
||||
viewerState.role == protocol.RoleViewer &&
|
||||
viewerState.roomID == roomID &&
|
||||
viewerState.peerID == viewer.PeerID &&
|
||||
viewerState.viewerPresence {
|
||||
add(viewerSession)
|
||||
}
|
||||
}
|
||||
for _, sess := range recipients {
|
||||
s.sendEncoded(sess, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastHostStatus ports broadcastHostStatus (O10).
|
||||
func (s *Server) broadcastHostStatus(roomID string, online, paused bool) {
|
||||
for _, viewer := range s.store.GetConnectedViewers(roomID) {
|
||||
s.sendToSession(viewer.SessionID, protocol.HostStatusMessage{
|
||||
Type: "host-status",
|
||||
Online: online,
|
||||
Paused: paused,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleDisconnect ports handleDisconnect. It runs once, from the reader
|
||||
// goroutine's exit (D6). The host lease write is one of the two paths the TS
|
||||
// left uncaught, so its failure panics (D7).
|
||||
func (s *Server) handleDisconnect(sess *session) {
|
||||
if !s.sessions.Has(sess) {
|
||||
return
|
||||
}
|
||||
sess.clearAuthenticationTimer()
|
||||
delete(s.senderQualityRateBySession, sess.sessionID)
|
||||
s.sessions.Delete(sess)
|
||||
if s.sessionsByID[sess.sessionID] == sess {
|
||||
delete(s.sessionsByID, sess.sessionID)
|
||||
}
|
||||
if sess.revoked {
|
||||
return
|
||||
}
|
||||
if sess.authenticated == nil {
|
||||
s.unauthenticatedConnections--
|
||||
return
|
||||
}
|
||||
if s.closing {
|
||||
return
|
||||
}
|
||||
|
||||
disconnected, err := s.store.DisconnectParticipant(
|
||||
sess.authenticated.roomID, sess.authenticated.peerID, sess.sessionID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("room store write failed during host disconnect: %w", err))
|
||||
}
|
||||
if disconnected == nil {
|
||||
return
|
||||
}
|
||||
s.router.disconnectParticipant(disconnected.RoomID, disconnected.PeerID, sess.sessionID)
|
||||
if disconnected.Role == protocol.RoleHost {
|
||||
s.broadcastHostStatus(disconnected.RoomID, false, false)
|
||||
s.sendViewerPresence(disconnected.RoomID)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendViewerPresence(disconnected.RoomID)
|
||||
|
||||
// T5: the viewer grace timer. The registered-pointer check stands in
|
||||
// for clearTimeout; the store's removeDisconnectedViewer is the identity
|
||||
// guard against a viewer that reconnected meanwhile.
|
||||
roomID, peerID := disconnected.RoomID, disconnected.PeerID
|
||||
key := viewerKey(roomID, peerID)
|
||||
timer := &graceTimer{}
|
||||
timer.stop = s.afterFunc(time.Duration(s.viewerDisconnectGraceMs)*time.Millisecond, func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.viewerGraceTimers[key] != timer {
|
||||
return
|
||||
}
|
||||
delete(s.viewerGraceTimers, key)
|
||||
if s.store.RemoveDisconnectedViewer(roomID, peerID) {
|
||||
s.clearViewerEvidence(roomID, peerID)
|
||||
s.router.removeViewer(roomID, peerID)
|
||||
}
|
||||
})
|
||||
s.viewerGraceTimers[key] = timer
|
||||
}
|
||||
|
||||
// stopSharing ports stopSharing (O9: per viewer, sharing-stopped then
|
||||
// host-status). The share generation deliberately survives (hazard 2).
|
||||
func (s *Server) stopSharing(roomID string) {
|
||||
if current, ok := s.shares[roomID]; ok {
|
||||
s.shares[roomID] = roomShare{generation: current.generation}
|
||||
}
|
||||
s.clearRoomViewerEvidence(roomID)
|
||||
s.router.stopRoom(roomID)
|
||||
for _, viewer := range s.store.GetConnectedViewers(roomID) {
|
||||
s.sendToSession(viewer.SessionID, protocol.SharingStoppedMessage{Type: "sharing-stopped"})
|
||||
s.sendToSession(viewer.SessionID, protocol.HostStatusMessage{
|
||||
Type: "host-status",
|
||||
Online: false,
|
||||
Paused: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// abandonRoom ports abandonRoom; the store error is the caller's to map.
|
||||
func (s *Server) abandonRoom(roomID string) error {
|
||||
abandoned, err := s.store.AbandonRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if abandoned == nil {
|
||||
return nil
|
||||
}
|
||||
s.closeRoom(*abandoned)
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeRoom ports closeRoom.
|
||||
func (s *Server) closeRoom(closed room.ClosedRoom) {
|
||||
s.terminateRoom(closed, "host-ended", "Room abandoned")
|
||||
}
|
||||
|
||||
// expireRooms ports expireRooms, the cleanup timer body. Its store write is
|
||||
// the second path the TS left uncaught (D7).
|
||||
func (s *Server) expireRooms() {
|
||||
expired, err := s.store.ExpireRooms(s.now())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("room store write failed while expiring rooms: %w", err))
|
||||
}
|
||||
for _, closed := range expired {
|
||||
s.terminateRoom(closed, "expired", "Room expired")
|
||||
}
|
||||
}
|
||||
|
||||
// terminateRoom ports terminateRoom (O4: the host session first, then
|
||||
// viewers in store order, each told room-closed then closed with 1000).
|
||||
func (s *Server) terminateRoom(closed room.ClosedRoom, reason, closeText string) {
|
||||
s.clearRoomGraceTimers(closed.RoomID)
|
||||
s.clearRoomViewerEvidence(closed.RoomID)
|
||||
s.router.deleteRoom(closed.RoomID)
|
||||
delete(s.shares, closed.RoomID)
|
||||
for _, sessionID := range closed.SessionIDs {
|
||||
sess := s.sessionsByID[sessionID]
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
s.send(sess, protocol.RoomClosedMessage{Type: "room-closed", Reason: reason})
|
||||
sess.close(websocket.StatusNormalClosure, closeText)
|
||||
}
|
||||
}
|
||||
|
||||
// revokeGrantViewers ports revokeGrantViewers (O5): three separate passes
|
||||
// over the revoked viewers, with presence deferred until the end.
|
||||
func (s *Server) revokeGrantViewers(roomID string, update room.ViewerGrantUpdate) {
|
||||
if len(update.RevokedViewers) == 0 {
|
||||
return
|
||||
}
|
||||
s.deferredViewerPresenceRooms[roomID] = struct{}{}
|
||||
func() {
|
||||
defer delete(s.deferredViewerPresenceRooms, roomID)
|
||||
for _, viewer := range update.RevokedViewers {
|
||||
s.clearViewerState(roomID, viewer.PeerID)
|
||||
if viewer.SessionID == "" {
|
||||
continue
|
||||
}
|
||||
s.sendToSession(viewer.SessionID, protocol.ViewerGrantRevokedMessage{
|
||||
Type: "viewer-grant-revoked",
|
||||
ViewerAuthorizationGeneration: update.PreviousViewerAuthorizationGeneration,
|
||||
})
|
||||
}
|
||||
|
||||
for _, viewer := range update.RevokedViewers {
|
||||
s.router.removeViewer(roomID, viewer.PeerID)
|
||||
}
|
||||
|
||||
for _, viewer := range update.RevokedViewers {
|
||||
if viewer.SessionID == "" {
|
||||
continue
|
||||
}
|
||||
s.closeRevokedViewerSession(viewer.SessionID)
|
||||
}
|
||||
}()
|
||||
s.sendViewerPresence(roomID)
|
||||
}
|
||||
|
||||
// closeRevokedViewerSession ports closeRevokedViewerSession: deauthorize
|
||||
// before the close so queued callbacks of the old generation cannot route
|
||||
// signaling after the persistent authorization commit.
|
||||
func (s *Server) closeRevokedViewerSession(sessionID string) {
|
||||
sess := s.sessionsByID[sessionID]
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
if sess.sessionID == sessionID {
|
||||
sess.clearAuthenticationTimer()
|
||||
sess.revoked = true
|
||||
}
|
||||
if s.sessionsByID[sessionID] == sess {
|
||||
delete(s.sessionsByID, sessionID)
|
||||
}
|
||||
sess.close(websocket.StatusCode(protocol.SignalCloseViewerAccessRevoked), "Viewer access revoked")
|
||||
}
|
||||
|
||||
// clearViewerGrace ports clearViewerGrace.
|
||||
func (s *Server) clearViewerGrace(roomID, peerID string) {
|
||||
key := viewerKey(roomID, peerID)
|
||||
if timer, ok := s.viewerGraceTimers[key]; ok {
|
||||
timer.stop()
|
||||
delete(s.viewerGraceTimers, key)
|
||||
}
|
||||
}
|
||||
|
||||
// clearViewerState ports clearViewerState.
|
||||
func (s *Server) clearViewerState(roomID, peerID string) {
|
||||
s.clearViewerGrace(roomID, peerID)
|
||||
s.clearViewerEvidence(roomID, peerID)
|
||||
}
|
||||
|
||||
// clearRoomGraceTimers ports clearRoomGraceTimers (O13: prefix scan; Go
|
||||
// maps allow deletion during range).
|
||||
func (s *Server) clearRoomGraceTimers(roomID string) {
|
||||
prefix := roomID + ":"
|
||||
for key, timer := range s.viewerGraceTimers {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
timer.stop()
|
||||
delete(s.viewerGraceTimers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearRoomViewerEvidence ports clearRoomConnectionIds (O13) without the
|
||||
// connection-ID mirror it also cleared.
|
||||
func (s *Server) clearRoomViewerEvidence(roomID string) {
|
||||
prefix := roomID + ":"
|
||||
for key := range s.viewerQualityEvidenceGates {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(s.viewerQualityEvidenceGates, key)
|
||||
}
|
||||
}
|
||||
for key := range s.viewerQualityEvidenceAttempts {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(s.viewerQualityEvidenceAttempts, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearViewerEvidence drops one viewer's evidence gate and rate stamp.
|
||||
func (s *Server) clearViewerEvidence(roomID, viewerPeerID string) {
|
||||
key := viewerKey(roomID, viewerPeerID)
|
||||
delete(s.viewerQualityEvidenceGates, key)
|
||||
delete(s.viewerQualityEvidenceAttempts, key)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,710 @@
|
||||
package signal
|
||||
|
||||
// The route pump of hybrid-media-router.ts (requestPump 1114, pumpRoom 1135,
|
||||
// prepareCandidate 1303, prepareNewPublication 1409, sendPrepareMessages 1492,
|
||||
// scheduleDeadline 1822), following map section 7 step by step. The only
|
||||
// unlock windows are inside prepareCandidate (R5, R6, R8, R9, R10); the
|
||||
// cursor guard captured before them is the sole staleness detector.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/room"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/route"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
||||
)
|
||||
|
||||
// preparedCandidate is PreparedCandidate; the optional TS keys are "" / nil.
|
||||
type preparedCandidate struct {
|
||||
reservation route.CandidateReservation
|
||||
connectionID string
|
||||
publicationGeneration string
|
||||
publicationConnectionID string
|
||||
hostSessionID string
|
||||
hostSfuConfig *protocol.SfuConfigMessage
|
||||
viewerSfuConfig *protocol.SfuConfigMessage
|
||||
}
|
||||
|
||||
// prepareResult is PrepareResult.
|
||||
type prepareResult struct {
|
||||
denied bool
|
||||
rejectionBucket route.RejectionBucket
|
||||
prepared preparedCandidate
|
||||
}
|
||||
|
||||
func denied(bucket route.RejectionBucket) prepareResult {
|
||||
return prepareResult{denied: true, rejectionBucket: bucket}
|
||||
}
|
||||
|
||||
// pumpCandidate is the pump loop state carried across the yield at
|
||||
// `await prepareCandidate`: the selected slot with its cursor guard and the
|
||||
// outcome of prepareCandidate's synchronous prefix. io is the awaited
|
||||
// remainder (drain wait, token issue, LiveKit room creation), nil when
|
||||
// result is already settled.
|
||||
type pumpCandidate struct {
|
||||
operation *route.OperationSnapshot
|
||||
plan route.CandidatePlan
|
||||
guard route.CandidateCursorGuard
|
||||
revision int64
|
||||
result prepareResult
|
||||
io func() prepareResult
|
||||
}
|
||||
|
||||
// requestPump ports requestPump: the single-flight driver. Every caller
|
||||
// only sets requested; the pump that owns pumping drains it.
|
||||
//
|
||||
// The TS driver was an async IIFE, so it ran synchronously inside the
|
||||
// caller up to its first await: the first pumpRoom iteration through the
|
||||
// synchronous prefix of prepareCandidate (reconcile, candidate selection,
|
||||
// the admission reservations and the LiveKit createRoom initiation). That
|
||||
// prefix is observable: the calls that follow requestPump in the same
|
||||
// handler see its reconcile, and two rooms woken by one drain reserve
|
||||
// SFU capacity in wake order (map O29). It therefore runs here in the
|
||||
// caller's critical section; everything after it ran in microtasks and
|
||||
// runs in drivePump's goroutine.
|
||||
//
|
||||
// Deviation: `pumping` is set before that prefix, where `room.pump` was
|
||||
// still undefined until the IIFE first suspended. A re-entrant requestPump
|
||||
// during the prefix therefore only sets `requested` here and is drained by
|
||||
// drivePump's loop, instead of starting a second overlapping pump as the
|
||||
// TypeScript would have. No work is lost and the single-operation model of
|
||||
// ADR-0005 is what a second pump would have put at risk.
|
||||
func (r *router) requestPump(roomID string) {
|
||||
rm, _ := r.rooms.Get(roomID)
|
||||
if rm == nil || rm.controller == nil || r.closing {
|
||||
return
|
||||
}
|
||||
rm.requested = true
|
||||
if rm.pumping {
|
||||
return
|
||||
}
|
||||
rm.pumping = true
|
||||
rm.requested = false
|
||||
controller := rm.controller
|
||||
broadcastRevision := controller.Revision()
|
||||
var pending *pumpCandidate
|
||||
failed := r.recoverPump(func() {
|
||||
pending = r.pumpRoom(roomID, rm, controller, &broadcastRevision, nil, true)
|
||||
})
|
||||
r.inflight++
|
||||
go r.drivePump(roomID, rm, controller, broadcastRevision, pending, failed)
|
||||
}
|
||||
|
||||
// recoverPump runs one pump segment; a panic is the TS rejection of the pump
|
||||
// promise (controller assertion failures panic with the TS messages, D11).
|
||||
func (r *router) recoverPump(segment func()) (failed bool) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
failed = true
|
||||
slog.Error("Media route reconciliation failed")
|
||||
}
|
||||
}()
|
||||
segment()
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePump is the asynchronous remainder of requestPump: the suspended
|
||||
// first iteration, the `while (room.requested)` loop, the `finally` that
|
||||
// frees the token and re-arms, and the `.catch` that tells the host.
|
||||
func (r *router) drivePump(roomID string, rm *roomRuntime, controller *route.Controller, broadcastRevision int64, pending *pumpCandidate, failed bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
defer func() { r.inflight-- }()
|
||||
if !failed {
|
||||
failed = r.recoverPump(func() {
|
||||
if pending != nil {
|
||||
r.pumpRoom(roomID, rm, controller, &broadcastRevision, pending, false)
|
||||
}
|
||||
for rm.requested && !r.closing {
|
||||
rm.requested = false
|
||||
if rm.controller == nil {
|
||||
continue
|
||||
}
|
||||
revision := rm.controller.Revision()
|
||||
r.pumpRoom(roomID, rm, rm.controller, &revision, nil, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
rm.pumping = false
|
||||
if rm.requested && !r.closing {
|
||||
r.requestPump(roomID)
|
||||
}
|
||||
if failed {
|
||||
r.sendRoomError(roomID, "SERVER_ERROR", "Media routing failed")
|
||||
}
|
||||
}
|
||||
|
||||
// pumpRoom ports pumpRoom. mu is held on entry and exit; the candidate's io
|
||||
// releases it around I/O. resume continues an iteration suspended at
|
||||
// `await prepareCandidate`; with yieldAtPrepare the first candidate is
|
||||
// returned after prepareCandidate's synchronous prefix instead of being
|
||||
// awaited (the TS suspension point). Otherwise nil.
|
||||
func (r *router) pumpRoom(
|
||||
roomID string,
|
||||
rm *roomRuntime,
|
||||
controller *route.Controller,
|
||||
broadcastRevision *int64,
|
||||
resume *pumpCandidate,
|
||||
yieldAtPrepare bool,
|
||||
) *pumpCandidate {
|
||||
broadcastRevisionChange := func() {
|
||||
revision := controller.Revision()
|
||||
if revision == *broadcastRevision {
|
||||
return
|
||||
}
|
||||
r.broadcastActive(roomID, rm)
|
||||
*broadcastRevision = revision
|
||||
}
|
||||
for {
|
||||
var candidate pumpCandidate
|
||||
if resume != nil {
|
||||
candidate, resume = *resume, nil
|
||||
} else {
|
||||
selected, done := r.selectCandidate(roomID, rm, controller, broadcastRevisionChange)
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
candidate = selected
|
||||
candidate.result, candidate.io = r.prepareCandidate(
|
||||
roomID, rm, controller, candidate.operation, candidate.plan, candidate.revision)
|
||||
if yieldAtPrepare {
|
||||
return &candidate
|
||||
}
|
||||
}
|
||||
operation, plan, guard := candidate.operation, candidate.plan, candidate.guard
|
||||
preparation := candidate.result
|
||||
if candidate.io != nil {
|
||||
preparation = candidate.io()
|
||||
}
|
||||
if preparation.denied {
|
||||
r.debug(roomID, "candidate-preparation-denied",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"route", r.debugTuple(roomID, plan.Tuple),
|
||||
"rejectionBucket", preparation.rejectionBucket,
|
||||
"cursor", operation.Cursor)
|
||||
if operation.Reason == route.DemandQualityConvergence ||
|
||||
operation.Reason == route.DemandRootConvergence {
|
||||
skipped := controller.SkipCurrentCandidate(guard, r.now(), preparation.rejectionBucket)
|
||||
r.releaseResources(skipped.Released)
|
||||
continue
|
||||
}
|
||||
if preparation.rejectionBucket != route.RejectionSfuAdmission {
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
}
|
||||
if preparation.rejectionBucket == route.RejectionSfuAdmission {
|
||||
if !controller.NoteCurrentCandidateRejection(guard, route.RejectionSfuAdmission) {
|
||||
continue
|
||||
}
|
||||
r.resourceWaiters.Set(roomID, struct{}{})
|
||||
r.sendViewerRouteStatus(roomID, operation.DemandPeerID, protocol.RouteStatusMessage{
|
||||
Type: "route-status",
|
||||
Revision: protocol.Int(operation.BaseRevision),
|
||||
State: "waiting",
|
||||
Reason: "sfu-admission",
|
||||
})
|
||||
r.scheduleDeadline(roomID, rm, operation)
|
||||
return nil
|
||||
}
|
||||
skipped := controller.SkipCurrentCandidate(guard, r.now(), preparation.rejectionBucket)
|
||||
r.releaseResources(skipped.Released)
|
||||
r.sendRouteFailures(roomID, skipped.FailedPeerIDs, controller.Revision())
|
||||
continue
|
||||
}
|
||||
prepared := preparation.prepared
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
r.debug(roomID, "candidate-prepared",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"route", r.debugTuple(roomID, plan.Tuple),
|
||||
"cursor", operation.Cursor)
|
||||
beginGuard := guard
|
||||
if plan.EndpointTransition.Kind == route.TransitionBoundedGap {
|
||||
gap := controller.RetireCurrentCandidateProducer(guard, r.now())
|
||||
r.releaseResources(gap.Released)
|
||||
r.sendRouteFailures(roomID, gap.FailedPeerIDs, controller.Revision())
|
||||
if !gap.Accepted {
|
||||
r.releaseReservation(prepared.reservation)
|
||||
continue
|
||||
}
|
||||
broadcastRevisionChange()
|
||||
currentOperation := controller.Operation()
|
||||
if currentOperation == nil || currentOperation.Cursor < 0 ||
|
||||
currentOperation.Cursor >= len(currentOperation.Candidates) {
|
||||
r.releaseReservation(prepared.reservation)
|
||||
continue
|
||||
}
|
||||
// The guard is re-derived: the retirement moved the revision.
|
||||
beginGuard = cursorGuard(currentOperation, currentOperation.Candidates[currentOperation.Cursor])
|
||||
}
|
||||
begun := controller.BeginCurrentCandidate(route.BeginInput{
|
||||
Guard: beginGuard,
|
||||
NowMs: r.now(),
|
||||
ConnectionID: prepared.connectionID,
|
||||
Reservation: prepared.reservation,
|
||||
PublicationGeneration: prepared.publicationGeneration,
|
||||
PublicationConnectionID: prepared.publicationConnectionID,
|
||||
HostSessionID: prepared.hostSessionID,
|
||||
})
|
||||
r.debug(roomID, "candidate-begin-settled",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"route", r.debugTuple(roomID, plan.Tuple),
|
||||
"accepted", begun.Accepted,
|
||||
"exhausted", len(begun.FailedPeerIDs) > 0)
|
||||
// A rejected begin returns the reservation inside begun.Released; it
|
||||
// must not be released a second time.
|
||||
r.releaseResources(begun.Released)
|
||||
if !begun.Accepted || begun.Operation == nil || begun.Operation.Current == nil {
|
||||
r.sendRouteFailures(roomID, begun.FailedPeerIDs, controller.Revision())
|
||||
continue
|
||||
}
|
||||
r.sendPrepareMessages(roomID, controller, begun.Operation, prepared)
|
||||
r.debug(roomID, "candidate-prepare-sent",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"route", r.debugTuple(roomID, plan.Tuple),
|
||||
"revision", begun.Operation.Current.Revision)
|
||||
r.scheduleDeadline(roomID, rm, begun.Operation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// selectCandidate is pumpRoom steps 1-7 (map 7.2): reconcile, then either
|
||||
// finish the iteration (done) or pick the cursor's plan and its guard.
|
||||
func (r *router) selectCandidate(
|
||||
roomID string,
|
||||
rm *roomRuntime,
|
||||
controller *route.Controller,
|
||||
broadcastRevisionChange func(),
|
||||
) (candidate pumpCandidate, done bool) {
|
||||
reconciled := controller.Reconcile(r.now())
|
||||
r.releaseResources(reconciled.Released)
|
||||
broadcastRevisionChange()
|
||||
r.sendRouteFailures(roomID, reconciled.FailedPeerIDs, controller.Revision())
|
||||
operation := reconciled.Operation
|
||||
if operation == nil {
|
||||
operation = controller.Operation()
|
||||
}
|
||||
if operation == nil {
|
||||
r.clearDeadline(rm)
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
return pumpCandidate{}, true
|
||||
}
|
||||
if operation.Current != nil {
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
r.scheduleDeadline(roomID, rm, operation)
|
||||
return pumpCandidate{}, true
|
||||
}
|
||||
if operation.Cursor < 0 || operation.Cursor >= len(operation.Candidates) {
|
||||
// TS: `if (!plan)` -- note: no clearDeadline here.
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
return pumpCandidate{}, true
|
||||
}
|
||||
plan := operation.Candidates[operation.Cursor]
|
||||
guard := cursorGuard(operation, plan)
|
||||
r.resourceWaiters.Delete(roomID)
|
||||
revision := operation.BaseRevision + 1
|
||||
if plan.EndpointTransition.Kind == route.TransitionBoundedGap {
|
||||
revision = operation.BaseRevision + 2
|
||||
}
|
||||
return pumpCandidate{operation: operation, plan: plan, guard: guard, revision: revision}, false
|
||||
}
|
||||
|
||||
// prepareCandidate ports prepareCandidate. It runs the synchronous prefix
|
||||
// (everything the TS executed before its first await: the stale checks,
|
||||
// the borrowed edge lookup, the admission reservations, the LiveKit
|
||||
// createRoom initiation) with mu held and returns either a settled result
|
||||
// or the awaited remainder as io. io is called with mu held and releases
|
||||
// it around reserveSfuSubscription's drain wait (R5/R10), the token issue
|
||||
// (R6) and, for a new publication, the LiveKit room creation (R8/R9). No
|
||||
// identity is re-read afterwards: the cursor guard in pumpRoom catches
|
||||
// staleness. controller is the pump's captured controller (TS read
|
||||
// room.controller!, the same object on every reachable path).
|
||||
func (r *router) prepareCandidate(
|
||||
roomID string,
|
||||
rm *roomRuntime,
|
||||
controller *route.Controller,
|
||||
operation *route.OperationSnapshot,
|
||||
plan route.CandidatePlan,
|
||||
revision int64,
|
||||
) (prepareResult, func() prepareResult) {
|
||||
snapshot := controller.Snapshot()
|
||||
child, hasChild := r.store.GetConnectedViewer(roomID, operation.ChildPeerID)
|
||||
shareGeneration := r.hooks.shareGeneration(roomID)
|
||||
if !hasChild || child.SessionID != operation.ChildSessionID || shareGeneration == "" {
|
||||
return denied(route.RejectionStale), nil
|
||||
}
|
||||
connectionID := opaqueID()
|
||||
var overlap *route.Resource
|
||||
if plan.EndpointTransition.Kind == route.TransitionOverlap {
|
||||
producerPeerID := ""
|
||||
if plan.EndpointTransition.ProducerPeerID != nil {
|
||||
producerPeerID = *plan.EndpointTransition.ProducerPeerID
|
||||
}
|
||||
overlap = overlapResource(producerPeerID)
|
||||
}
|
||||
if plan.Tuple.Kind == route.UpstreamPeer {
|
||||
return prepareResult{prepared: preparedCandidate{
|
||||
connectionID: connectionID,
|
||||
reservation: route.CandidateReservation{Kind: route.ReservationDirect, Overlap: overlap},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
fallback := r.sfu
|
||||
host, hasHost := r.store.GetConnectedHost(roomID)
|
||||
if fallback == nil || !hasHost || host.PeerID != rm.hostPeerID {
|
||||
return denied(route.RejectionStale), nil
|
||||
}
|
||||
if plan.Tuple.Publication == route.PublicationReuse {
|
||||
publication := snapshot.HostPublication
|
||||
if publication == nil || publication.Resource == nil ||
|
||||
publication.Resource.Kind != route.ResourceSfuPublication {
|
||||
return denied(route.RejectionStale), nil
|
||||
}
|
||||
fence := sfu.SubscriptionFence{
|
||||
ResourceFence: resourceFence(publication.Resource),
|
||||
ViewerPeerID: operation.ChildPeerID,
|
||||
}
|
||||
var borrowedEdge *route.Resource
|
||||
if currentEdge, ok := snapshot.UpstreamByViewer.Get(operation.ChildPeerID); ok &&
|
||||
currentEdge.Kind == route.UpstreamSfu &&
|
||||
currentEdge.PublicationGeneration == publication.Generation &&
|
||||
currentEdge.Resource != nil &&
|
||||
currentEdge.Resource.Kind == route.ResourceSfuSubscription &&
|
||||
!currentEdge.Resource.Released {
|
||||
borrowedEdge = currentEdge.Resource
|
||||
}
|
||||
// issue is the remainder after `await this.reserveSfuSubscription`.
|
||||
issue := func(edge *route.Resource) prepareResult {
|
||||
request := sfu.TokenRequest{
|
||||
RoomID: roomID,
|
||||
Role: protocol.RoleViewer,
|
||||
PeerID: operation.ChildPeerID,
|
||||
ShareGeneration: shareGeneration,
|
||||
PublicationGeneration: publication.Generation,
|
||||
}
|
||||
var token string
|
||||
var err error
|
||||
r.io(func() { token, err = fallback.tokenIssuer.IssueToken(request) }) // R6
|
||||
if err != nil {
|
||||
if borrowedEdge == nil {
|
||||
r.releaseResource(edge)
|
||||
}
|
||||
return denied(route.RejectionCandidateFailed)
|
||||
}
|
||||
return prepareResult{prepared: preparedCandidate{
|
||||
connectionID: connectionID,
|
||||
hostSessionID: host.SessionID,
|
||||
reservation: route.CandidateReservation{
|
||||
Kind: route.ReservationSfuReuse,
|
||||
Edge: edge,
|
||||
Borrowed: borrowedEdge != nil,
|
||||
Overlap: overlap,
|
||||
},
|
||||
publicationGeneration: publication.Generation,
|
||||
viewerSfuConfig: &protocol.SfuConfigMessage{
|
||||
Type: "sfu-config",
|
||||
Revision: protocol.Int(revision),
|
||||
URL: fallback.url,
|
||||
Token: token,
|
||||
},
|
||||
}}
|
||||
}
|
||||
if borrowedEdge != nil {
|
||||
return prepareResult{}, func() prepareResult { return issue(borrowedEdge) }
|
||||
}
|
||||
// reserveSfuSubscription (1788): a pending drain of the same
|
||||
// subscription is awaited first (R10); otherwise the reservation is
|
||||
// part of the synchronous prefix.
|
||||
key := sfuDrainKey(drainSubscription, fence)
|
||||
if pending, _ := r.sfuDrainTasks.Get(key); pending != nil && pending.operation != nil {
|
||||
drain := pending.operation
|
||||
return prepareResult{}, func() prepareResult {
|
||||
r.io(func() { <-drain.done })
|
||||
if drain.err != nil {
|
||||
return denied(route.RejectionSfuAdmission)
|
||||
}
|
||||
// Hazard 8: a drain that settled but left its task
|
||||
// registered means refuse.
|
||||
if registered, _ := r.sfuDrainTasks.Get(key); registered == pending {
|
||||
return denied(route.RejectionSfuAdmission)
|
||||
}
|
||||
if !r.reserveSfuSubscription(fence) {
|
||||
return denied(route.RejectionSfuAdmission)
|
||||
}
|
||||
return issue(subscriptionResource(fence))
|
||||
}
|
||||
}
|
||||
if !r.reserveSfuSubscription(fence) {
|
||||
return denied(route.RejectionSfuAdmission), nil
|
||||
}
|
||||
edge := subscriptionResource(fence)
|
||||
return prepareResult{}, func() prepareResult { return issue(edge) }
|
||||
}
|
||||
|
||||
return r.prepareNewPublication(roomID, operation, revision, connectionID, shareGeneration, host, overlap)
|
||||
}
|
||||
|
||||
// prepareNewPublication ports prepareNewPublication. The reservations and
|
||||
// the room's FIFO turn (the TS createRoom call, registered synchronously
|
||||
// by LiveKitSfuRoomControl.serialize) belong to the synchronous prefix;
|
||||
// io creates the LiveKit room inside that turn and issues the two tokens
|
||||
// sequentially where the TS used Promise.all (hazard 6: latency only).
|
||||
// Failure releases the subscription before the publication (O26), the
|
||||
// reverse of releaseResources.
|
||||
func (r *router) prepareNewPublication(
|
||||
roomID string,
|
||||
operation *route.OperationSnapshot,
|
||||
revision int64,
|
||||
connectionID string,
|
||||
shareGeneration string,
|
||||
host room.ConnectedPeer,
|
||||
overlap *route.Resource,
|
||||
) (prepareResult, func() prepareResult) {
|
||||
fallback := r.sfu
|
||||
publicationGeneration := opaqueID()
|
||||
publicationFence := sfu.ResourceFence{
|
||||
RoomID: roomID,
|
||||
ShareGeneration: shareGeneration,
|
||||
PublicationGeneration: publicationGeneration,
|
||||
}
|
||||
subscriptionFence := sfu.SubscriptionFence{
|
||||
ResourceFence: publicationFence,
|
||||
ViewerPeerID: operation.ChildPeerID,
|
||||
}
|
||||
if !fallback.admission.ReservePublication(publicationFence) {
|
||||
return denied(route.RejectionSfuAdmission), nil
|
||||
}
|
||||
if !fallback.admission.ReserveSubscription(subscriptionFence) {
|
||||
fallback.admission.BeginDrain(publicationFence)
|
||||
r.scheduleSfuPublicationDrain(publicationFence)
|
||||
return denied(route.RejectionSfuAdmission), nil
|
||||
}
|
||||
subscription := subscriptionResource(subscriptionFence)
|
||||
publicationConnectionID := publicationGeneration
|
||||
publication := publicationResource(publicationFence)
|
||||
turn := r.takeTurn(sfu.ManagedRoomName(publicationFence))
|
||||
return prepareResult{}, func() prepareResult {
|
||||
var err error
|
||||
var hostToken, viewerToken string
|
||||
r.io(func() {
|
||||
turn.run(func() {
|
||||
err = fallback.roomControl.CreateRoom(context.Background(), publicationFence) // R8
|
||||
})
|
||||
if err == nil { // R9
|
||||
hostToken, err = fallback.tokenIssuer.IssueToken(sfu.TokenRequest{
|
||||
RoomID: roomID,
|
||||
Role: protocol.RoleHost,
|
||||
PeerID: host.PeerID,
|
||||
ShareGeneration: shareGeneration,
|
||||
PublicationGeneration: publicationGeneration,
|
||||
})
|
||||
}
|
||||
if err == nil {
|
||||
viewerToken, err = fallback.tokenIssuer.IssueToken(sfu.TokenRequest{
|
||||
RoomID: roomID,
|
||||
Role: protocol.RoleViewer,
|
||||
PeerID: operation.ChildPeerID,
|
||||
ShareGeneration: shareGeneration,
|
||||
PublicationGeneration: publicationGeneration,
|
||||
})
|
||||
}
|
||||
})
|
||||
r.dropTurn(turn)
|
||||
if err != nil {
|
||||
r.releaseResource(subscription)
|
||||
r.releaseResource(publication)
|
||||
return denied(route.RejectionCandidateFailed)
|
||||
}
|
||||
return prepareResult{prepared: preparedCandidate{
|
||||
connectionID: connectionID,
|
||||
hostSessionID: host.SessionID,
|
||||
publicationGeneration: publicationGeneration,
|
||||
publicationConnectionID: publicationConnectionID,
|
||||
reservation: route.CandidateReservation{
|
||||
Kind: route.ReservationSfuCreate,
|
||||
Edge: subscription,
|
||||
Publication: publication,
|
||||
Overlap: overlap,
|
||||
},
|
||||
hostSfuConfig: &protocol.SfuConfigMessage{
|
||||
Type: "sfu-config",
|
||||
Revision: protocol.Int(revision),
|
||||
URL: fallback.url,
|
||||
Token: hostToken,
|
||||
},
|
||||
viewerSfuConfig: &protocol.SfuConfigMessage{
|
||||
Type: "sfu-config",
|
||||
Revision: protocol.Int(revision),
|
||||
URL: fallback.url,
|
||||
Token: viewerToken,
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// sendPrepareMessages ports sendPrepareMessages (O24: fixed order). It sends
|
||||
// nothing at all when the child is stale or the peer parent vanished; the
|
||||
// deadline timer then resolves the operation (hazard 7).
|
||||
func (r *router) sendPrepareMessages(
|
||||
roomID string,
|
||||
controller *route.Controller,
|
||||
operation *route.OperationSnapshot,
|
||||
prepared preparedCandidate,
|
||||
) {
|
||||
current := operation.Current
|
||||
candidate := preparedRouteCandidate(operation, current.Tuple, current.ConnectionID)
|
||||
tuple := current.Tuple
|
||||
assignments := r.assignments(roomID, controller.Snapshot(), &tuple, operation.ChildPeerID, prepared.publicationGeneration)
|
||||
child, hasChild := r.store.GetConnectedViewer(roomID, operation.ChildPeerID)
|
||||
if !hasChild || child.SessionID != operation.ChildSessionID {
|
||||
return
|
||||
}
|
||||
prepare := func(peerID string) protocol.RouteUpdatePrepareMessage {
|
||||
return protocol.RouteUpdatePrepareMessage{
|
||||
Type: "route-update",
|
||||
Revision: protocol.Int(current.Revision),
|
||||
Phase: "prepare",
|
||||
Assignment: assignmentFor(assignments, peerID),
|
||||
Candidate: candidate,
|
||||
}
|
||||
}
|
||||
childUpdate := prepare(operation.ChildPeerID)
|
||||
if current.Tuple.Kind == route.UpstreamPeer {
|
||||
parent, hasParent := r.connectedPeer(roomID, current.Tuple.ParentPeerID)
|
||||
if !hasParent {
|
||||
return
|
||||
}
|
||||
r.hooks.sendToSession(child.SessionID, childUpdate)
|
||||
r.hooks.sendToSession(parent.SessionID, prepare(parent.PeerID))
|
||||
return
|
||||
}
|
||||
host, hasHost := r.store.GetConnectedHost(roomID)
|
||||
if prepared.hostSfuConfig != nil && hasHost && host.SessionID == prepared.hostSessionID {
|
||||
r.hooks.sendToSession(host.SessionID, prepare(host.PeerID))
|
||||
r.hooks.sendToSession(host.SessionID, *prepared.hostSfuConfig)
|
||||
}
|
||||
r.hooks.sendToSession(child.SessionID, childUpdate)
|
||||
if prepared.viewerSfuConfig != nil {
|
||||
r.hooks.sendToSession(child.SessionID, *prepared.viewerSfuConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleDeadline ports scheduleDeadline (T6). The callback verifies it is
|
||||
// still the registered deadline through the room's generation counter, which
|
||||
// clearDeadline bumps: a cleared TS timer never fired (D5).
|
||||
func (r *router) scheduleDeadline(roomID string, rm *roomRuntime, operation *route.OperationSnapshot) {
|
||||
r.clearDeadline(rm)
|
||||
wakeInMs := max(int64(0), operation.WakeAtMs-r.now())
|
||||
if routeDebugEnabled {
|
||||
var current any
|
||||
if operation.Current != nil {
|
||||
current = r.debugTuple(roomID, operation.Current.Tuple)
|
||||
}
|
||||
r.debug(roomID, "deadline-scheduled",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"wakeInMs", wakeInMs,
|
||||
"cursor", operation.Cursor,
|
||||
"candidateCount", len(operation.Candidates),
|
||||
"current", current)
|
||||
}
|
||||
generation := rm.deadlineGeneration
|
||||
rm.deadlineStop = r.afterFunc(time.Duration(wakeInMs)*time.Millisecond, func() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if rm.deadlineGeneration != generation || rm.controller == nil || r.closing {
|
||||
return
|
||||
}
|
||||
rm.deadlineStop = nil
|
||||
before := rm.controller.Revision()
|
||||
expired := rm.controller.OperationExpired(r.now())
|
||||
r.debug(roomID, "deadline-fired",
|
||||
"child", r.debugPeer(roomID, operation.ChildPeerID),
|
||||
"accepted", expired.Accepted,
|
||||
"exhausted", len(expired.FailedPeerIDs) > 0)
|
||||
r.releaseResources(expired.Released)
|
||||
if rm.controller.Revision() != before {
|
||||
r.broadcastActive(roomID, rm)
|
||||
}
|
||||
r.sendRouteFailures(roomID, expired.FailedPeerIDs, rm.controller.Revision())
|
||||
r.requestPump(roomID)
|
||||
})
|
||||
}
|
||||
|
||||
// clearDeadline ports clearDeadline; bumping the generation retires a
|
||||
// callback that already lost the Stop race.
|
||||
func (r *router) clearDeadline(rm *roomRuntime) {
|
||||
if rm.deadlineStop != nil {
|
||||
rm.deadlineStop()
|
||||
}
|
||||
rm.deadlineStop = nil
|
||||
rm.deadlineGeneration++
|
||||
}
|
||||
|
||||
func cursorGuard(operation *route.OperationSnapshot, plan route.CandidatePlan) route.CandidateCursorGuard {
|
||||
return route.CandidateCursorGuard{
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ChildSessionID: operation.ChildSessionID,
|
||||
BaseRevision: operation.BaseRevision,
|
||||
FactVersion: operation.FactVersion,
|
||||
Cursor: operation.Cursor,
|
||||
Plan: plan,
|
||||
}
|
||||
}
|
||||
|
||||
// preparedRouteCandidate ports preparedRouteCandidate; connectionAttempt is
|
||||
// present only when the current attempt carries one.
|
||||
func preparedRouteCandidate(operation *route.OperationSnapshot, tuple route.CandidateTuple, connectionID string) protocol.PreparedRouteCandidate {
|
||||
transport := "sfu"
|
||||
if tuple.Kind == route.UpstreamPeer {
|
||||
transport = string(tuple.Transport)
|
||||
}
|
||||
candidate := protocol.PreparedRouteCandidate{
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ConnectionID: connectionID,
|
||||
Transport: transport,
|
||||
QualityProbe: operation.Reason == route.DemandQualityConvergence,
|
||||
}
|
||||
if operation.Current != nil && operation.Current.ConnectionAttempt != nil {
|
||||
candidate.ConnectionAttempt = &protocol.ConnectionAttempt{
|
||||
Current: protocol.Int(operation.Current.ConnectionAttempt.Current),
|
||||
Total: protocol.Int(operation.Current.ConnectionAttempt.Total),
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func overlapResource(endpointPeerID string) *route.Resource {
|
||||
return &route.Resource{Kind: route.ResourceOverlap, EndpointPeerID: endpointPeerID}
|
||||
}
|
||||
|
||||
func subscriptionResource(fence sfu.SubscriptionFence) *route.Resource {
|
||||
return &route.Resource{
|
||||
Kind: route.ResourceSfuSubscription,
|
||||
RoomID: fence.RoomID,
|
||||
ShareGeneration: fence.ShareGeneration,
|
||||
PublicationGeneration: fence.PublicationGeneration,
|
||||
ViewerPeerID: fence.ViewerPeerID,
|
||||
}
|
||||
}
|
||||
|
||||
func publicationResource(fence sfu.ResourceFence) *route.Resource {
|
||||
return &route.Resource{
|
||||
Kind: route.ResourceSfuPublication,
|
||||
RoomID: fence.RoomID,
|
||||
ShareGeneration: fence.ShareGeneration,
|
||||
PublicationGeneration: fence.PublicationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
func resourceFence(resource *route.Resource) sfu.ResourceFence {
|
||||
return sfu.ResourceFence{
|
||||
RoomID: resource.RoomID,
|
||||
ShareGeneration: resource.ShareGeneration,
|
||||
PublicationGeneration: resource.PublicationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionFence(resource *route.Resource) sfu.SubscriptionFence {
|
||||
return sfu.SubscriptionFence{ResourceFence: resourceFence(resource), ViewerPeerID: resource.ViewerPeerID}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package signal
|
||||
|
||||
// The SFU side of hybrid-media-router.ts: reservation commit and release
|
||||
// (1734-1786), subscription reservation across a pending drain (1788),
|
||||
// resource waiters (1811), fresh SFU configuration (1865), the host-offline
|
||||
// probe (1903-2013) and the LiveKit drains (2015-2089).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/route"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
||||
)
|
||||
|
||||
type drainKind string
|
||||
|
||||
const (
|
||||
drainPublication drainKind = "publication"
|
||||
drainSubscription drainKind = "subscription"
|
||||
)
|
||||
|
||||
// sfuDrainTask is SfuDrainTask; fence.ViewerPeerID is "" for a publication.
|
||||
// operation is the in-flight drain (TS task.operation); retryGeneration is
|
||||
// bumped by clearDrainRetry so a retry callback that lost the Stop race sees
|
||||
// it is stale (D5).
|
||||
type sfuDrainTask struct {
|
||||
kind drainKind
|
||||
fence sfu.SubscriptionFence
|
||||
operation *drainOperation
|
||||
retryStop func() bool
|
||||
retryGeneration uint64
|
||||
}
|
||||
|
||||
// drainOperation is the promise runSfuDrain tracked: done is closed after err
|
||||
// is set, with mu held, so a waiter that relocks sees the settled task.
|
||||
type drainOperation struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
// turn is one slot in the per managed-room-name FIFO.
|
||||
//
|
||||
// The TS LiveKitSfuRoomControl.serialize registered every operation
|
||||
// synchronously at call time, so operations on one managed room ran in the
|
||||
// order the router decided them. In Go the RoomControl call happens after mu
|
||||
// is released, and two goroutines could reach LiveKit in the opposite order
|
||||
// (a drain deciding after a create but deleting the room before it exists).
|
||||
// takeTurn therefore reserves the slot while mu is still held; run waits for
|
||||
// the predecessor outside mu, performs the call and releases the successor;
|
||||
// dropTurn forgets the tail entry once mu is held again. The sfu package's
|
||||
// own serialisation then sees calls already in decision order. Only the
|
||||
// mutating calls (CreateRoom, DeleteRoom, DrainSubscription) take a turn:
|
||||
// their relative order is the correctness property; HostParticipantExists
|
||||
// is a read whose result is re-validated against the controller anyway, and
|
||||
// the TS suite's fake control never queued a drain behind it.
|
||||
type turn struct {
|
||||
name string
|
||||
previous chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// takeTurn reserves the next slot for roomName. mu must be held.
|
||||
func (r *router) takeTurn(roomName string) *turn {
|
||||
done := make(chan struct{})
|
||||
current := &turn{name: roomName, previous: r.roomTurns[roomName], done: done}
|
||||
r.roomTurns[roomName] = done
|
||||
return current
|
||||
}
|
||||
|
||||
// run performs op in turn order. mu must NOT be held.
|
||||
func (t *turn) run(op func()) {
|
||||
if t.previous != nil {
|
||||
<-t.previous
|
||||
}
|
||||
defer close(t.done)
|
||||
op()
|
||||
}
|
||||
|
||||
// dropTurn removes the FIFO tail once it is this turn. mu must be held.
|
||||
func (r *router) dropTurn(t *turn) {
|
||||
if r.roomTurns[t.name] == t.done {
|
||||
delete(r.roomTurns, t.name)
|
||||
}
|
||||
}
|
||||
|
||||
// commitReservation ports commitReservation (O30: drained generations in
|
||||
// admission order).
|
||||
func (r *router) commitReservation(reservation route.CandidateReservation) bool {
|
||||
if reservation.Kind == route.ReservationDirect {
|
||||
return true
|
||||
}
|
||||
if reservation.Kind == route.ReservationSfuReuse {
|
||||
return reservation.Edge != nil &&
|
||||
reservation.Edge.Kind == route.ResourceSfuSubscription &&
|
||||
r.sfu != nil &&
|
||||
r.sfu.admission.CommitSubscription(subscriptionFence(reservation.Edge))
|
||||
}
|
||||
if reservation.Publication == nil || reservation.Publication.Kind != route.ResourceSfuPublication {
|
||||
return false
|
||||
}
|
||||
if r.sfu == nil {
|
||||
return false
|
||||
}
|
||||
draining, ok := r.sfu.admission.CommitPublication(resourceFence(reservation.Publication))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, fence := range draining {
|
||||
r.scheduleSfuPublicationDrain(fence)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *router) releaseReservation(reservation route.CandidateReservation) {
|
||||
r.releaseResources(reservationResources(reservation))
|
||||
}
|
||||
|
||||
// releaseResources ports releaseResources (O25). Publication drains own
|
||||
// accounting; subscription drains only remove exact participants.
|
||||
func (r *router) releaseResources(resources []*route.Resource) {
|
||||
for _, resource := range resources {
|
||||
if resource.Kind == route.ResourceSfuPublication {
|
||||
r.releaseResource(resource)
|
||||
}
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Kind != route.ResourceSfuPublication {
|
||||
r.releaseResource(resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// releaseResource ports releaseResource: idempotent through Released.
|
||||
func (r *router) releaseResource(resource *route.Resource) {
|
||||
if resource.Released {
|
||||
return
|
||||
}
|
||||
resource.Released = true
|
||||
switch resource.Kind {
|
||||
case route.ResourceOverlap:
|
||||
return
|
||||
case route.ResourceSfuSubscription:
|
||||
r.releaseSubscription(subscriptionFence(resource))
|
||||
return
|
||||
}
|
||||
if r.sfu != nil && r.sfu.admission.BeginDrain(resourceFence(resource)) {
|
||||
r.scheduleSfuPublicationDrain(resourceFence(resource))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) releaseSubscription(fence sfu.SubscriptionFence) bool {
|
||||
if r.sfu == nil {
|
||||
return false
|
||||
}
|
||||
accepted := r.sfu.admission.BeginSubscriptionDrain(fence)
|
||||
if accepted {
|
||||
r.scheduleSfuSubscriptionDrain(fence)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
// reserveSfuSubscription ports the synchronous tail of reserveSfuSubscription
|
||||
// (1803-1808); the pending-drain wait before it (R10) lives in
|
||||
// prepareCandidate, where the TS suspended.
|
||||
func (r *router) reserveSfuSubscription(fence sfu.SubscriptionFence) bool {
|
||||
fallback := r.sfu
|
||||
if fallback == nil {
|
||||
return false
|
||||
}
|
||||
key := sfuDrainKey(drainSubscription, fence)
|
||||
if task, _ := r.sfuDrainTasks.Get(key); task != nil && task.kind == drainSubscription {
|
||||
r.clearDrainRetry(task)
|
||||
r.sfuDrainTasks.Delete(key)
|
||||
}
|
||||
return fallback.admission.ReserveSubscription(fence)
|
||||
}
|
||||
|
||||
// wakeResourceWaiters ports wakeResourceWaiters (O29: snapshot order).
|
||||
func (r *router) wakeResourceWaiters() {
|
||||
roomIDs := r.resourceWaiters.Keys()
|
||||
r.resourceWaiters.Clear()
|
||||
for _, roomID := range roomIDs {
|
||||
rm, _ := r.rooms.Get(roomID)
|
||||
if rm == nil || rm.controller == nil {
|
||||
continue
|
||||
}
|
||||
rm.controller.TouchExternalFacts()
|
||||
r.requestPump(roomID)
|
||||
}
|
||||
}
|
||||
|
||||
// sendFreshSfuConfig ports sendFreshSfuConfig (R11). The pre-await guards run
|
||||
// in the caller's critical section, as the TS ran them before its first
|
||||
// await; the token is issued in a goroutine with mu released and the
|
||||
// post-await guards re-run under mu with the pre-await snapshot. failed runs
|
||||
// where the TS `.catch` ran and settled where `.finally` ran; both may be nil
|
||||
// and both run under mu.
|
||||
func (r *router) sendFreshSfuConfig(participant authenticatedRouteParticipant, failed, settled func()) {
|
||||
finish := func(err error) {
|
||||
if err != nil && failed != nil {
|
||||
failed()
|
||||
}
|
||||
if settled != nil {
|
||||
settled()
|
||||
}
|
||||
}
|
||||
fallback := r.sfu
|
||||
rm, _ := r.rooms.Get(participant.roomID)
|
||||
shareGeneration := r.hooks.shareGeneration(participant.roomID)
|
||||
if fallback == nil || rm == nil || rm.controller == nil {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
snapshot := rm.controller.Snapshot()
|
||||
publication := snapshot.HostPublication
|
||||
if publication == nil || !publication.PhysicalActive || shareGeneration == "" {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
isHost := participant.peerID == rm.hostPeerID
|
||||
if !isHost {
|
||||
edge, ok := snapshot.UpstreamByViewer.Get(participant.peerID)
|
||||
if !ok || edge.Kind != route.UpstreamSfu || !edge.PhysicalActive {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
role := protocol.RoleViewer
|
||||
if isHost {
|
||||
role = protocol.RoleHost
|
||||
}
|
||||
request := sfu.TokenRequest{
|
||||
RoomID: participant.roomID,
|
||||
Role: role,
|
||||
PeerID: participant.peerID,
|
||||
ShareGeneration: shareGeneration,
|
||||
PublicationGeneration: publication.Generation,
|
||||
}
|
||||
r.inflight++
|
||||
go func() {
|
||||
token, err := fallback.tokenIssuer.IssueToken(request)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.inflight--
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
if rm.controller == nil {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
currentPublication := rm.controller.Publication()
|
||||
peer, connected := r.connectedPeer(participant.roomID, participant.peerID)
|
||||
if rm.controller.Revision() != snapshot.Revision ||
|
||||
currentPublication == nil ||
|
||||
currentPublication.Generation != publication.Generation ||
|
||||
!connected || peer.SessionID != participant.sessionID {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
r.hooks.sendToSession(participant.sessionID, protocol.SfuConfigMessage{
|
||||
Type: "sfu-config",
|
||||
Revision: protocol.Int(snapshot.Revision),
|
||||
URL: fallback.url,
|
||||
Token: token,
|
||||
})
|
||||
finish(nil)
|
||||
}()
|
||||
}
|
||||
|
||||
// activeHostPublication ports activeHostPublication.
|
||||
func (r *router) activeHostPublication(roomID string) (hostOfflineCheck, bool) {
|
||||
rm, _ := r.rooms.Get(roomID)
|
||||
if rm == nil || rm.controller == nil {
|
||||
return hostOfflineCheck{}, false
|
||||
}
|
||||
publication := rm.controller.Publication()
|
||||
if publication == nil || !publication.PhysicalActive ||
|
||||
publication.Resource == nil || publication.Resource.Kind != route.ResourceSfuPublication {
|
||||
return hostOfflineCheck{}, false
|
||||
}
|
||||
return hostOfflineCheck{
|
||||
fence: resourceFence(publication.Resource),
|
||||
hostSessionID: publication.HostSessionID,
|
||||
connectionID: publication.ConnectionID,
|
||||
}, true
|
||||
}
|
||||
|
||||
// scheduleHostOfflineCheck ports scheduleHostOfflineCheck (T7).
|
||||
func (r *router) scheduleHostOfflineCheck(roomID, hostPeerID string) {
|
||||
r.cancelHostOfflineCheck(roomID)
|
||||
fallback := r.sfu
|
||||
publication, ok := r.activeHostPublication(roomID)
|
||||
if fallback == nil || !ok || r.closing {
|
||||
return
|
||||
}
|
||||
check := &publication
|
||||
check.hostPeerID = hostPeerID
|
||||
delay := fallback.hostOfflineCheckMs
|
||||
if delay == 0 {
|
||||
delay = defaultHostOfflineCheckMs
|
||||
}
|
||||
check.stop = r.afterFunc(time.Duration(delay)*time.Millisecond, func() {
|
||||
r.checkHostOffline(roomID, check)
|
||||
})
|
||||
r.hostOfflineChecks.Set(roomID, check)
|
||||
}
|
||||
|
||||
// checkHostOffline ports checkHostOffline (R12) as the timer callback body.
|
||||
// armed is the check the timer was armed with: a cleared TS timer never
|
||||
// fired, so anything else registered for the room means return (D5).
|
||||
func (r *router) checkHostOffline(roomID string, armed *hostOfflineCheck) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
check, _ := r.hostOfflineChecks.Get(roomID)
|
||||
fallback := r.sfu
|
||||
if check == nil || check != armed || fallback == nil || r.closing {
|
||||
return
|
||||
}
|
||||
if _, hostConnected := r.store.GetConnectedHost(roomID); hostConnected {
|
||||
r.cancelHostOfflineCheck(roomID)
|
||||
return
|
||||
}
|
||||
var exists bool
|
||||
var err error
|
||||
r.io(func() {
|
||||
exists, err = fallback.roomControl.HostParticipantExists(context.Background(), check.fence)
|
||||
})
|
||||
if err != nil {
|
||||
if registered, _ := r.hostOfflineChecks.Get(roomID); registered != check {
|
||||
return
|
||||
}
|
||||
r.hostOfflineChecks.Delete(roomID)
|
||||
r.scheduleHostOfflineCheck(roomID, check.hostPeerID)
|
||||
return
|
||||
}
|
||||
if registered, _ := r.hostOfflineChecks.Get(roomID); registered != check {
|
||||
return
|
||||
}
|
||||
r.hostOfflineChecks.Delete(roomID)
|
||||
if exists {
|
||||
r.scheduleHostOfflineCheck(roomID, check.hostPeerID)
|
||||
return
|
||||
}
|
||||
rm, _ := r.rooms.Get(roomID)
|
||||
if rm == nil || rm.controller == nil {
|
||||
return
|
||||
}
|
||||
controller := rm.controller
|
||||
snapshot := controller.Snapshot()
|
||||
publication := snapshot.HostPublication
|
||||
if publication == nil {
|
||||
return
|
||||
}
|
||||
// Hazard 9: six publication fields plus the fence triple; anything
|
||||
// short of that re-arms the check instead of retiring the publication.
|
||||
if publication.Resource == nil ||
|
||||
publication.Resource.Kind != route.ResourceSfuPublication ||
|
||||
resourceFence(publication.Resource) != check.fence ||
|
||||
publication.Generation != check.fence.PublicationGeneration ||
|
||||
publication.HostSessionID != check.hostSessionID ||
|
||||
publication.ConnectionID != check.connectionID ||
|
||||
!publication.PhysicalActive {
|
||||
r.scheduleHostOfflineCheck(roomID, check.hostPeerID)
|
||||
return
|
||||
}
|
||||
beforeRevision := snapshot.Revision
|
||||
controller.InvalidateHostPublication(route.PublicationGuard{
|
||||
HostSessionID: publication.HostSessionID,
|
||||
RouteRevision: snapshot.Revision,
|
||||
Generation: publication.Generation,
|
||||
ConnectionID: publication.ConnectionID,
|
||||
}, r.nowPtr())
|
||||
r.releaseResources(controller.RetireHostPublication(route.PublicationGuard{
|
||||
HostSessionID: publication.HostSessionID,
|
||||
RouteRevision: controller.Revision(),
|
||||
Generation: publication.Generation,
|
||||
ConnectionID: publication.ConnectionID,
|
||||
}))
|
||||
afterRetirement := controller.Publication()
|
||||
if afterRetirement != nil &&
|
||||
afterRetirement.Generation == publication.Generation &&
|
||||
afterRetirement.ConnectionID == publication.ConnectionID &&
|
||||
afterRetirement.PhysicalActive {
|
||||
if controller.Revision() != beforeRevision {
|
||||
r.broadcastActive(roomID, rm)
|
||||
}
|
||||
r.scheduleHostOfflineCheck(roomID, check.hostPeerID)
|
||||
r.requestPump(roomID)
|
||||
return
|
||||
}
|
||||
r.broadcastActive(roomID, rm)
|
||||
r.requestPump(roomID)
|
||||
}
|
||||
|
||||
func (r *router) cancelHostOfflineCheck(roomID string) {
|
||||
if check, ok := r.hostOfflineChecks.Get(roomID); ok {
|
||||
check.stop()
|
||||
}
|
||||
r.hostOfflineChecks.Delete(roomID)
|
||||
}
|
||||
|
||||
// scheduleSfuPublicationDrain ports scheduleSfuPublicationDrain (O28: the
|
||||
// sibling subscription tasks are deleted during the live iteration).
|
||||
func (r *router) scheduleSfuPublicationDrain(fence sfu.ResourceFence) {
|
||||
roomName := sfu.ManagedRoomName(fence)
|
||||
for key, task := range r.sfuDrainTasks.All() {
|
||||
if task.kind == drainSubscription && sfu.ManagedRoomName(task.fence.ResourceFence) == roomName {
|
||||
r.clearDrainRetry(task)
|
||||
r.sfuDrainTasks.Delete(key)
|
||||
}
|
||||
}
|
||||
r.scheduleSfuDrain(drainPublication, sfu.SubscriptionFence{ResourceFence: fence})
|
||||
}
|
||||
|
||||
// scheduleSfuSubscriptionDrain ports scheduleSfuSubscriptionDrain: a pending
|
||||
// publication drain of the same room already removes every participant.
|
||||
func (r *router) scheduleSfuSubscriptionDrain(fence sfu.SubscriptionFence) {
|
||||
if r.sfuDrainTasks.Has(sfu.ManagedRoomName(fence.ResourceFence)) {
|
||||
return
|
||||
}
|
||||
r.scheduleSfuDrain(drainSubscription, fence)
|
||||
}
|
||||
|
||||
// scheduleSfuDrain ports scheduleSfuDrain.
|
||||
func (r *router) scheduleSfuDrain(kind drainKind, fence sfu.SubscriptionFence) {
|
||||
if r.sfu == nil {
|
||||
return
|
||||
}
|
||||
key := sfuDrainKey(kind, fence)
|
||||
task, _ := r.sfuDrainTasks.Get(key)
|
||||
if task == nil {
|
||||
task = &sfuDrainTask{kind: kind, fence: fence}
|
||||
r.sfuDrainTasks.Set(key, task)
|
||||
}
|
||||
if task.operation == nil && task.retryStop == nil {
|
||||
// TS: void this.runSfuDrain(key, task).catch(() => undefined)
|
||||
r.runSfuDrain(r.drainContext(), key, task)
|
||||
}
|
||||
}
|
||||
|
||||
// runSfuDrain ports runSfuDrain (R13, R14). mu must be held; the returned
|
||||
// operation is the tracked promise. The LiveKit call runs in a goroutine
|
||||
// inside the room's FIFO turn taken here, and the post-await body runs under
|
||||
// mu before done is closed.
|
||||
func (r *router) runSfuDrain(ctx context.Context, key string, task *sfuDrainTask) *drainOperation {
|
||||
if task.operation != nil {
|
||||
return task.operation
|
||||
}
|
||||
operation := &drainOperation{done: make(chan struct{})}
|
||||
fallback := r.sfu
|
||||
if fallback == nil {
|
||||
close(operation.done)
|
||||
return operation
|
||||
}
|
||||
task.operation = operation
|
||||
turn := r.takeTurn(sfu.ManagedRoomName(task.fence.ResourceFence))
|
||||
r.inflight++
|
||||
go func() {
|
||||
var err error
|
||||
turn.run(func() {
|
||||
if task.kind == drainPublication {
|
||||
err = fallback.roomControl.DeleteRoom(ctx, task.fence.ResourceFence)
|
||||
} else {
|
||||
err = fallback.roomControl.DrainSubscription(ctx, task.fence)
|
||||
}
|
||||
})
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.inflight--
|
||||
r.dropTurn(turn)
|
||||
operation.err = r.settleSfuDrain(key, task, err)
|
||||
if task.operation == operation {
|
||||
task.operation = nil
|
||||
}
|
||||
close(operation.done)
|
||||
}()
|
||||
return operation
|
||||
}
|
||||
|
||||
// settleSfuDrain is the post-await body of runSfuDrain: the try/catch after
|
||||
// the LiveKit call. It returns the error the tracked promise rejected with.
|
||||
func (r *router) settleSfuDrain(key string, task *sfuDrainTask, err error) error {
|
||||
fallback := r.sfu
|
||||
if err == nil {
|
||||
if registered, _ := r.sfuDrainTasks.Get(key); registered != task {
|
||||
return nil
|
||||
}
|
||||
if task.kind == drainPublication && !fallback.admission.CompleteDrain(task.fence.ResourceFence) {
|
||||
err = errors.New("LiveKit drain has no matching resource generation")
|
||||
} else {
|
||||
r.sfuDrainTasks.Delete(key)
|
||||
if task.kind == drainPublication {
|
||||
r.wakeResourceWaiters()
|
||||
} else {
|
||||
r.requestPump(task.fence.RoomID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// catch
|
||||
if registered, _ := r.sfuDrainTasks.Get(key); registered != task {
|
||||
return nil
|
||||
}
|
||||
if r.closing {
|
||||
return err
|
||||
}
|
||||
retryMs := fallback.drainRetryMs
|
||||
if retryMs == 0 {
|
||||
retryMs = defaultSfuDrainRetryMs
|
||||
}
|
||||
generation := task.retryGeneration
|
||||
task.retryStop = r.afterFunc(time.Duration(retryMs)*time.Millisecond, func() { // T8
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if task.retryGeneration != generation || r.closing {
|
||||
return
|
||||
}
|
||||
task.retryStop = nil
|
||||
r.runSfuDrain(r.drainContext(), key, task)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainContext bounds the LiveKit drains: unbounded (the TS had none) until
|
||||
// close installs its context (D6).
|
||||
func (r *router) drainContext() context.Context {
|
||||
if r.closeCtx != nil {
|
||||
return r.closeCtx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// clearDrainRetry is the clearTimeout(task.retryTimer) of the TS; bumping
|
||||
// the generation retires a callback that already lost the Stop race.
|
||||
func (r *router) clearDrainRetry(task *sfuDrainTask) {
|
||||
if task.retryStop != nil {
|
||||
task.retryStop()
|
||||
task.retryStop = nil
|
||||
}
|
||||
task.retryGeneration++
|
||||
}
|
||||
|
||||
// awaitDrain is `await task.operation` inside close(): mu is released while
|
||||
// waiting and re-acquired before returning; ctx bounds the wait (D6).
|
||||
func (r *router) awaitDrain(ctx context.Context, operation *drainOperation) error {
|
||||
var err error
|
||||
r.io(func() {
|
||||
select {
|
||||
case <-operation.done:
|
||||
err = operation.err
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func sfuDrainKey(kind drainKind, fence sfu.SubscriptionFence) string {
|
||||
roomName := sfu.ManagedRoomName(fence.ResourceFence)
|
||||
if kind == drainPublication {
|
||||
return roomName
|
||||
}
|
||||
return roomName + "\x00viewer:" + fence.ViewerPeerID
|
||||
}
|
||||
|
||||
// reservationResources ports reservationResources: a borrowed reuse edge
|
||||
// belongs to another holder and is not released.
|
||||
func reservationResources(reservation route.CandidateReservation) []*route.Resource {
|
||||
var resources []*route.Resource
|
||||
if reservation.Edge != nil && (reservation.Kind != route.ReservationSfuReuse || !reservation.Borrowed) {
|
||||
resources = append(resources, reservation.Edge)
|
||||
}
|
||||
if reservation.Publication != nil {
|
||||
resources = append(resources, reservation.Publication)
|
||||
}
|
||||
if reservation.Overlap != nil {
|
||||
resources = append(resources, reservation.Overlap)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,305 @@
|
||||
package signal
|
||||
|
||||
// The per-connection state of src/server/signaling.ts (SocketState,
|
||||
// accept(), the socket event handlers, send()/sendEncodedToSession(),
|
||||
// heartbeat()). One reader goroutine and one writer goroutine per
|
||||
// connection; the writer drains an outbound queue that handlers fill under
|
||||
// mu, which is what the ws library's own send buffer did (D4).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// authenticatedSession is AuthenticatedSession. Handlers compare it by
|
||||
// pointer (hazard 1): `state.authenticated !== authenticated` detects a
|
||||
// re-authentication between dispatch and use. shareGeneration is "" where
|
||||
// the TS had null; displayName is nil where the TS had null.
|
||||
type authenticatedSession struct {
|
||||
roomID string
|
||||
role protocol.Role
|
||||
peerID string
|
||||
shareGeneration string
|
||||
displayName *string
|
||||
viewerPresence bool
|
||||
}
|
||||
|
||||
// outbound is one queue item: a text frame or the close frame that ends the
|
||||
// connection.
|
||||
type outbound struct {
|
||||
data []byte
|
||||
close bool
|
||||
code websocket.StatusCode
|
||||
reason string
|
||||
}
|
||||
|
||||
// session is SocketState plus the connection it belongs to. Every field is
|
||||
// guarded by Server.mu except conn, ctx, cancel, wake and readerDone, which
|
||||
// are immutable after accept.
|
||||
type session struct {
|
||||
conn *websocket.Conn
|
||||
// ctx is cancelled by terminate(); coder/websocket closes the socket
|
||||
// when the context of a pending Read or Write expires, which is the
|
||||
// `socket.terminate()` of the TS.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
sessionID string
|
||||
alive bool
|
||||
siteAccessAuthenticated bool
|
||||
revoked bool
|
||||
authenticating bool
|
||||
// challenged is `lastSignalingChallengeAtMs !== undefined`.
|
||||
challenged bool
|
||||
lastSignalingChallengeAtMs int64
|
||||
authenticated *authenticatedSession
|
||||
|
||||
// authStop stops the authentication deadline (T3); authGeneration is
|
||||
// bumped whenever the TS cleared the timer so a callback that lost the
|
||||
// Stop race sees it is stale (D5).
|
||||
authStop func() bool
|
||||
authGeneration uint64
|
||||
|
||||
// queue is the ws send buffer; queuedBytes is its bufferedAmount.
|
||||
queue []outbound
|
||||
queuedBytes int
|
||||
wake chan struct{}
|
||||
// closeQueued is readyState !== OPEN once close() was called: later
|
||||
// sends are dropped and later closes are no-ops, as with ws.
|
||||
closeQueued bool
|
||||
terminated bool
|
||||
readerDone chan struct{}
|
||||
}
|
||||
|
||||
func newSession(conn *websocket.Conn, siteAccessAuthenticated bool) *session {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &session{
|
||||
conn: conn,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
sessionID: opaqueID(),
|
||||
alive: true,
|
||||
siteAccessAuthenticated: siteAccessAuthenticated,
|
||||
wake: make(chan struct{}, 1),
|
||||
readerDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// open is `socket.readyState === WebSocket.OPEN`.
|
||||
func (sess *session) open() bool {
|
||||
return !sess.closeQueued && !sess.terminated
|
||||
}
|
||||
|
||||
func (sess *session) notify() {
|
||||
select {
|
||||
case sess.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue appends a text frame. mu must be held.
|
||||
func (sess *session) enqueue(encoded []byte) {
|
||||
sess.queue = append(sess.queue, outbound{data: encoded})
|
||||
sess.queuedBytes += len(encoded)
|
||||
sess.notify()
|
||||
}
|
||||
|
||||
// close is socket.close(code, reason): a no-op unless the socket is open.
|
||||
// mu must be held.
|
||||
func (sess *session) close(code websocket.StatusCode, reason string) {
|
||||
if !sess.open() {
|
||||
return
|
||||
}
|
||||
sess.closeQueued = true
|
||||
sess.queue = append(sess.queue, outbound{close: true, code: code, reason: reason})
|
||||
sess.notify()
|
||||
}
|
||||
|
||||
// terminate is socket.terminate(): abrupt, no close frame. Cancelling the
|
||||
// context closes the socket from inside the library, so nothing touches the
|
||||
// connection under mu. mu must be held.
|
||||
func (sess *session) terminate() {
|
||||
if sess.terminated {
|
||||
return
|
||||
}
|
||||
sess.terminated = true
|
||||
sess.cancel()
|
||||
}
|
||||
|
||||
// clearAuthenticationTimer is clearTimeout(state.authenticationTimer).
|
||||
func (sess *session) clearAuthenticationTimer() {
|
||||
if sess.authStop != nil {
|
||||
sess.authStop()
|
||||
sess.authStop = nil
|
||||
}
|
||||
sess.authGeneration++
|
||||
}
|
||||
|
||||
// accept ports accept(): the slot is counted here, the two goroutines start
|
||||
// here. mu must be held.
|
||||
func (s *Server) accept(conn *websocket.Conn, siteAccessAuthenticated bool) *session {
|
||||
if !s.hasConnectionCapacity() {
|
||||
go func() { _ = conn.CloseNow() }()
|
||||
return nil
|
||||
}
|
||||
sess := newSession(conn, siteAccessAuthenticated)
|
||||
s.armAuthenticationTimer(sess)
|
||||
s.sessions.Set(sess, struct{}{})
|
||||
s.sessionsByID[sess.sessionID] = sess
|
||||
s.unauthenticatedConnections++
|
||||
go s.readLoop(sess)
|
||||
go s.writeLoop(sess)
|
||||
return sess
|
||||
}
|
||||
|
||||
// armAuthenticationTimer is the setTimeout of accept() (T3). The TS timer
|
||||
// fired blind; the generation check stands in for clearTimeout.
|
||||
func (s *Server) armAuthenticationTimer(sess *session) {
|
||||
generation := sess.authGeneration
|
||||
sess.authStop = s.afterFunc(time.Duration(s.authenticationTimeoutMs)*time.Millisecond, func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess.authGeneration != generation {
|
||||
return
|
||||
}
|
||||
sess.authStop = nil
|
||||
s.sendError(sess, "AUTH_REQUIRED", "Authentication timed out")
|
||||
sess.close(websocket.StatusCode(protocol.SignalCloseAuthenticationFailed), "Authentication required")
|
||||
})
|
||||
}
|
||||
|
||||
// readLoop is the "message" handler plus the "close" handler: it reads until
|
||||
// the connection fails and then runs handleDisconnect exactly once (D6).
|
||||
func (s *Server) readLoop(sess *session) {
|
||||
defer func() {
|
||||
sess.cancel()
|
||||
s.mu.Lock()
|
||||
s.handleDisconnect(sess)
|
||||
s.mu.Unlock()
|
||||
close(sess.readerDone)
|
||||
}()
|
||||
for {
|
||||
kind, data, err := sess.conn.Read(sess.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if kind == websocket.MessageBinary {
|
||||
// TS: if (isBinary) rejectInvalidMessage(socket)
|
||||
s.rejectInvalidMessage(sess)
|
||||
} else {
|
||||
s.handleMessage(sess, data)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// writeLoop drains the outbound queue. Conn.Write and Conn.Close are only
|
||||
// ever called here, never under mu (D4).
|
||||
func (s *Server) writeLoop(sess *session) {
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if len(sess.queue) == 0 {
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-sess.wake:
|
||||
continue
|
||||
case <-sess.ctx.Done():
|
||||
_ = sess.conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
item := sess.queue[0]
|
||||
sess.queue[0] = outbound{}
|
||||
sess.queue = sess.queue[1:]
|
||||
s.mu.Unlock()
|
||||
if item.close {
|
||||
_ = sess.conn.Close(item.code, item.reason)
|
||||
return
|
||||
}
|
||||
err := sess.conn.Write(sess.ctx, websocket.MessageText, item.data)
|
||||
s.mu.Lock()
|
||||
sess.queuedBytes -= len(item.data)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
_ = sess.conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ping is the socket.ping() of heartbeat(), fired outside mu. The pong is
|
||||
// read by readLoop; the timeout bounds the wait, it does not close the
|
||||
// socket (the next heartbeat tick does, through alive).
|
||||
func (sess *session) ping(timeout time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(sess.ctx, timeout)
|
||||
defer cancel()
|
||||
_ = sess.conn.Ping(ctx)
|
||||
}
|
||||
|
||||
// heartbeat ports heartbeat() (O11: insertion order). It returns the
|
||||
// sessions to ping so the caller can do that after releasing mu.
|
||||
func (s *Server) heartbeat() []*session {
|
||||
var pings []*session
|
||||
for sess := range s.sessions.All() {
|
||||
if !sess.alive {
|
||||
sess.terminate()
|
||||
continue
|
||||
}
|
||||
sess.alive = false
|
||||
pings = append(pings, sess)
|
||||
}
|
||||
return pings
|
||||
}
|
||||
|
||||
// send ports send(): drop unless open, terminate above the buffer bound,
|
||||
// otherwise queue the encoded message.
|
||||
func (s *Server) send(sess *session, message protocol.ServerMessage) {
|
||||
encoded, err := protocol.EncodeServerMessage(message)
|
||||
if err != nil {
|
||||
// JSON.stringify of a server literal cannot fail; neither can this.
|
||||
panic(err)
|
||||
}
|
||||
s.sendEncoded(sess, encoded)
|
||||
}
|
||||
|
||||
// sendEncoded is the shared body of send() and sendEncodedToSession().
|
||||
func (s *Server) sendEncoded(sess *session, encoded []byte) {
|
||||
if !sess.open() {
|
||||
return
|
||||
}
|
||||
if sess.queuedBytes > maxBufferedSignalBytes {
|
||||
sess.terminate()
|
||||
return
|
||||
}
|
||||
sess.enqueue(encoded)
|
||||
}
|
||||
|
||||
// sendToSession ports sendToSession().
|
||||
func (s *Server) sendToSession(sessionID string, message protocol.ServerMessage) {
|
||||
if sess := s.sessionsByID[sessionID]; sess != nil {
|
||||
s.send(sess, message)
|
||||
}
|
||||
}
|
||||
|
||||
// sendEncodedToSession ports sendEncodedToSession(): the same bytes go to
|
||||
// every recipient (hazard 10).
|
||||
func (s *Server) sendEncodedToSession(sessionID string, encoded []byte) {
|
||||
if sess := s.sessionsByID[sessionID]; sess != nil {
|
||||
s.sendEncoded(sess, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendError(sess *session, code, message string) {
|
||||
s.send(sess, protocol.ErrorMessage{Type: "error", Code: code, Message: message})
|
||||
}
|
||||
|
||||
// rejectInvalidMessage ports rejectInvalidMessage().
|
||||
func (s *Server) rejectInvalidMessage(sess *session) {
|
||||
s.sendError(sess, "INVALID_MESSAGE", "Message is invalid")
|
||||
sess.close(websocket.StatusPolicyViolation, "Invalid message")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package webassets embeds the built Browser UI so a single binary can serve it.
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// `all:` is the only pattern that still builds on a clean checkout, where dist
|
||||
// holds nothing but the tracked .gitkeep marker. It also embeds dot-prefixed
|
||||
// entries, which the static handler refuses to serve.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
// FS returns the embedded Vite build, or nil when no build was embedded. The
|
||||
// probe is dist/index.html, never an entry count: .gitkeep is always there.
|
||||
func FS() fs.FS {
|
||||
assets, err := fs.Sub(embedded, "dist")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fs.Stat(assets, "index.html"); err != nil {
|
||||
return nil
|
||||
}
|
||||
return assets
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFSTracksIndexHTML holds whether or not a Vite build is present: FS() must
|
||||
// be non-nil exactly when dist/index.html is embedded.
|
||||
func TestFSTracksIndexHTML(t *testing.T) {
|
||||
_, err := fs.Stat(embedded, "dist/index.html")
|
||||
assets := FS()
|
||||
|
||||
switch {
|
||||
case err == nil && assets == nil:
|
||||
t.Fatal("dist/index.html is embedded but FS() returned nil")
|
||||
case err != nil && assets != nil:
|
||||
t.Fatalf("dist/index.html is absent (%v) but FS() returned a file system", err)
|
||||
case assets == nil:
|
||||
t.Log("no build embedded: the app serves the API only")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(assets, "index.html"); err != nil {
|
||||
t.Fatalf("index.html is not reachable through FS(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitkeepIsEmbedded pins the `all:` prefix: without it the package does not
|
||||
// build on a checkout whose dist holds only the dot-prefixed marker.
|
||||
func TestGitkeepIsEmbedded(t *testing.T) {
|
||||
if _, err := fs.Stat(embedded, "dist/.gitkeep"); err != nil {
|
||||
t.Fatalf("dist/.gitkeep must stay tracked and embedded: %v", err)
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Reference in new issue
Block a user