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:
AAA authored and GitHub committed 2026-09-06 23:26:01 +08:00
1 parent 0653b7a58a
commit da47189fc3
291 files changed
+42361 -34024

No files matched your search

+179
View File
@@ -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
}
+284
View File
@@ -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")
}
}
+429
View File
@@ -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
+148
View File
@@ -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
}
+442
View File
@@ -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
}
+162
View File
@@ -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
}
+227
View File
@@ -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)
}
}
}
+538
View File
@@ -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
}
+558
View File
@@ -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,
}
}
+81
View File
@@ -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
}
+136
View File
@@ -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))
}
})
}
}
+151
View File
@@ -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
}
+210
View File
@@ -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)
}
})
}
}
+154
View File
@@ -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
}
+79
View File
@@ -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")
}
}
+698
View File
@@ -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
}
+206
View File
@@ -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
}
+75
View File
@@ -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,
}
+659
View File
@@ -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)
}
}
}
+149
View File
@@ -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
}
+201
View File
@@ -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
}
+108
View File
@@ -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)
}
})
}
}
+197
View File
@@ -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() {}
+401
View File
@@ -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
}
+207
View File
@@ -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)
}
+691
View File
@@ -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
+155
View File
@@ -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
}
+786
View File
@@ -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, &notNull, &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
}
+684
View File
@@ -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)
}
}
+88
View File
@@ -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
+145
View File
@@ -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
Binary file not shown.
+519
View File
@@ -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
}
+562
View File
@@ -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
}
+995
View File
@@ -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
+72
View File
@@ -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)
}
+15
View File
@@ -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
+287
View File
@@ -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
}
+570
View File
@@ -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
+270
View File
@@ -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)
}
}
+875
View File
@@ -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)
}
}
}
+131
View File
@@ -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
}
+263
View File
@@ -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
}
+83
View File
@@ -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)
}
}
+193
View File
@@ -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
}
+424
View File
@@ -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
}
+390
View File
@@ -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)
})
}
}
+189
View File
@@ -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
}
+438
View File
@@ -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)
}
}
+188
View File
@@ -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)
}
+178
View File
@@ -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)
}
}
}
+271
View File
@@ -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()
}
}
+193
View File
@@ -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)
}
+203
View File
@@ -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 }
+225
View File
@@ -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)
}
}
+352
View File
@@ -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
+710
View File
@@ -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}
}
+585
View File
@@ -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
+305
View File
@@ -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")
}
+27
View File
@@ -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
}
+35
View File
@@ -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)
}
}
View File
Whitespace-only changes.