## Problem and outcome / 问题与结果 Compatible Native receiver renegotiation rebuilt the encoded source and its playback/relay consumers. Replacement backends could also lose committed-route recovery ownership, while queued events and unfinished SFU cleanup could reach a replacement with the same logical identity. Retain compatible receivers, keep recovery on the committed route, and validate physical ownership through final control delivery. SFU activation waits for the existing retirement barrier. This phase also consumes renewed same-page invitations through the existing grant owner and avoids closing uninitialized Windows capture objects after preview failure. Camera, microphone, interaction and control redesign remain outside this patch. ## Release notes ### 中文 这是一次以连接稳定性为重点的小修复更新。 - 修复 App 接收端重新协商时不必要地中断播放和中继的问题,并保留正确的连接恢复行为。 - 修复旧连接的迟到事件及关闭时序干扰替代连接的问题。 - 修复重新打开同页邀请链接后仍沿用旧邀请的问题。 - 修复 Windows 选源预览失败时的一处异常退出。 更新 App 并刷新网页即可使用这些修复,无需修改配置。 ### English This maintenance update focuses on connection stability. - Preserve App playback, relay connections and recovery behavior during compatible renegotiation. - Prevent delayed events and cleanup from retired connections from disrupting their replacements. - Use the new invitation when another invite link opens in the same page. - Fix a Windows crash during failed source-preview cleanup. Update Piik App and refresh the page to use these fixes. No configuration changes are required. ## Verification / 验证 - TypeScript type checking, 71 Vitest files / 1,015 tests and Web production build. - Go core tests, vet and supported cross-target builds; receiver tests exercise H.264/VP8/Opus, ICE restart, fresh gathering and retained downstream media. - Windows native compile/probe and deterministic capture checks, including failed-preview cleanup. - Documentation and repository hygiene checks; independent review of the extracted patch and old/new Web/App capability negotiation. - Local full-suite runs reached the unchanged Git fixture's 5-second timeout; that suite passed alone. The complete suite was rerun with two workers and a 15-second test deadline. ## Impact / 影响 Planned version: v1.5.6. Existing public wire versions and configuration are retained. Receiver reuse is capability-negotiated: old Apps receive no new request field, and old pages receive no new answer field. Room authority, routing policy and retry budgets are unchanged. The established-viewing interruption report still lacks paired diagnostics; these reproduced defects do not establish that reporter's exact cause. Physical device/network endurance remains outside local deterministic acceptance. Git/PRs retain this repair history; the TODO ledger retains outstanding reports and separate feature candidates.
596 lines
16 KiB
Go
596 lines
16 KiB
Go
package loopback
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
const (
|
|
defaultBindAddress = "127.0.0.1"
|
|
instanceTokenBytes = 32
|
|
helloTimeout = 5 * time.Second
|
|
shutdownTimeout = 2 * time.Second
|
|
controlQueueSize = 64 // MaxControlMessageBytes * 64 = 16 MiB worst-case.
|
|
maxControlSessions = 2
|
|
)
|
|
|
|
type Options struct {
|
|
PortStart int
|
|
PortEnd int
|
|
AllowedOrigins []string
|
|
NativeMedia NativeMediaCapabilities
|
|
NewControl func() ControlSession
|
|
Presentation func(language string)
|
|
}
|
|
|
|
type ControlSession interface {
|
|
// Errors reject protocol violations and close this control connection.
|
|
// Valid operation failures return a response instead of ending the session.
|
|
// A nil response with no error is completed later through Events, using the request ID.
|
|
Handle(context.Context, []byte) (any, error)
|
|
Events() <-chan any
|
|
Close() error
|
|
}
|
|
|
|
// ControlEvent retains a physical owner's validity through asynchronous queues.
|
|
// Current must only inspect ownership; it runs under the WebSocket write lock.
|
|
type ControlEvent struct {
|
|
Value any
|
|
Current func() bool
|
|
}
|
|
|
|
type Endpoint struct {
|
|
URL string `json:"url"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
InstanceToken string `json:"instanceToken"`
|
|
}
|
|
|
|
type Health struct {
|
|
Protocol int `json:"protocol"`
|
|
Service string `json:"service"`
|
|
Port int `json:"port"`
|
|
InstanceToken string `json:"instanceToken"`
|
|
NativeMedia NativeMediaCapabilities `json:"nativeMedia"`
|
|
}
|
|
|
|
type NativeMediaCapabilities struct {
|
|
ReceiverReuse bool `json:"receiverReuse,omitempty"`
|
|
Video bool `json:"video"`
|
|
CaptureBorderControl bool `json:"captureBorderControl,omitempty"`
|
|
ProcessAudio bool `json:"processAudio"`
|
|
SystemAudio bool `json:"systemAudio"`
|
|
HardwareH264 bool `json:"hardwareH264"`
|
|
SoftwareVP8 bool `json:"softwareVP8"`
|
|
}
|
|
|
|
type Server struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
listener net.Listener
|
|
httpServer *http.Server
|
|
endpoint Endpoint
|
|
allowedOrigins []string
|
|
nativeMedia NativeMediaCapabilities
|
|
newControl func() ControlSession
|
|
presentation func(string)
|
|
done chan error
|
|
|
|
mu sync.Mutex
|
|
connections map[*websocket.Conn]struct{}
|
|
claimed int
|
|
closed bool
|
|
controls sync.WaitGroup
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
}
|
|
|
|
func Start(parent context.Context, options Options) (*Server, error) {
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
portStart, portEnd := normalizePortRange(options.PortStart, options.PortEnd)
|
|
listener, err := listenInRange(defaultBindAddress, portStart, portEnd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
address, ok := listener.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
_ = listener.Close()
|
|
return nil, errors.New("loopback listener returned an unexpected address")
|
|
}
|
|
instanceToken, err := newInstanceToken()
|
|
if err != nil {
|
|
_ = listener.Close()
|
|
return nil, fmt.Errorf("create helper instance token: %w", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(parent)
|
|
server := &Server{
|
|
ctx: ctx, cancel: cancel, listener: listener,
|
|
endpoint: Endpoint{
|
|
URL: "http://" + listener.Addr().String(),
|
|
Host: listener.Addr().String(),
|
|
Port: address.Port,
|
|
InstanceToken: instanceToken,
|
|
},
|
|
allowedOrigins: normalizedOrigins(options.AllowedOrigins),
|
|
nativeMedia: options.NativeMedia,
|
|
newControl: options.NewControl,
|
|
presentation: options.Presentation,
|
|
done: make(chan error, 1),
|
|
connections: make(map[*websocket.Conn]struct{}),
|
|
}
|
|
server.httpServer = &http.Server{
|
|
Handler: server,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
IdleTimeout: 30 * time.Second,
|
|
}
|
|
go server.serve()
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = server.Close()
|
|
}()
|
|
return server, nil
|
|
}
|
|
|
|
func (server *Server) Endpoint() Endpoint {
|
|
return server.endpoint
|
|
}
|
|
|
|
func (server *Server) Done() <-chan error {
|
|
return server.done
|
|
}
|
|
|
|
func (server *Server) SetAllowedOrigins(origins []string) {
|
|
server.mu.Lock()
|
|
server.allowedOrigins = normalizedOrigins(origins)
|
|
server.mu.Unlock()
|
|
}
|
|
|
|
func (server *Server) Close() error {
|
|
server.closeOnce.Do(func() {
|
|
server.mu.Lock()
|
|
server.closed = true
|
|
connections := make([]*websocket.Conn, 0, len(server.connections))
|
|
for connection := range server.connections {
|
|
connections = append(connections, connection)
|
|
}
|
|
server.mu.Unlock()
|
|
server.cancel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
for _, connection := range connections {
|
|
_ = connection.CloseNow()
|
|
}
|
|
server.closeErr = server.httpServer.Shutdown(ctx)
|
|
retired := make(chan struct{})
|
|
go func() {
|
|
server.controls.Wait()
|
|
close(retired)
|
|
}()
|
|
select {
|
|
case <-retired:
|
|
case <-ctx.Done():
|
|
server.closeErr = errors.Join(server.closeErr, ctx.Err())
|
|
}
|
|
})
|
|
return server.closeErr
|
|
}
|
|
|
|
func (server *Server) serve() {
|
|
err := server.httpServer.Serve(server.listener)
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
server.cancel()
|
|
}
|
|
server.done <- err
|
|
close(server.done)
|
|
}
|
|
|
|
func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
|
if !server.requestAllowed(response, request) {
|
|
return
|
|
}
|
|
switch request.URL.Path {
|
|
case "/health":
|
|
server.handleHealth(response, request)
|
|
case "/control":
|
|
server.handleControl(response, request)
|
|
case "/presentation":
|
|
server.handlePresentation(response, request)
|
|
default:
|
|
http.NotFound(response, request)
|
|
}
|
|
}
|
|
|
|
func (server *Server) handlePresentation(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
response.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
|
|
response.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
allowPrivateNetwork(response, request)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if request.Method != http.MethodPost {
|
|
response.Header().Set("Allow", "POST, OPTIONS")
|
|
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
request.Body = http.MaxBytesReader(response, request.Body, 256)
|
|
var presentation struct {
|
|
Language string `json:"language"`
|
|
}
|
|
decoder := json.NewDecoder(request.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if decoder.Decode(&presentation) != nil || decoder.Decode(&struct{}{}) != io.EOF ||
|
|
(presentation.Language != "zh" && presentation.Language != "en" && presentation.Language != "vis") {
|
|
http.Error(response, "invalid presentation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if server.presentation != nil {
|
|
server.presentation(presentation.Language)
|
|
}
|
|
response.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (server *Server) handleHealth(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
response.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
|
|
allowPrivateNetwork(response, request)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if request.Method != http.MethodGet {
|
|
response.Header().Set("Allow", "GET, OPTIONS")
|
|
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
response.Header().Set("Cache-Control", "no-store")
|
|
writeJSON(response, http.StatusOK, Health{
|
|
Protocol: ProtocolVersion,
|
|
Service: ServiceName,
|
|
Port: server.endpoint.Port,
|
|
InstanceToken: server.endpoint.InstanceToken,
|
|
NativeMedia: server.nativeMedia,
|
|
})
|
|
}
|
|
|
|
func allowPrivateNetwork(response http.ResponseWriter, request *http.Request) {
|
|
if strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Private-Network")), "true") {
|
|
response.Header().Set("Access-Control-Allow-Private-Network", "true")
|
|
}
|
|
}
|
|
|
|
func (server *Server) handleControl(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodGet {
|
|
response.Header().Set("Allow", http.MethodGet)
|
|
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !server.claimConnection() {
|
|
http.Error(response, "native control capacity is occupied", http.StatusConflict)
|
|
return
|
|
}
|
|
var connection *websocket.Conn
|
|
defer func() { server.releaseConnection(connection) }()
|
|
expectedSubprotocol := ControlSubprotocol + "." + server.endpoint.InstanceToken
|
|
var err error
|
|
connection, err = websocket.Accept(response, request, &websocket.AcceptOptions{
|
|
Subprotocols: []string{expectedSubprotocol},
|
|
OriginPatterns: server.originPatterns(),
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer connection.CloseNow()
|
|
if connection.Subprotocol() != expectedSubprotocol {
|
|
closeControl(connection, "unsupported control protocol")
|
|
return
|
|
}
|
|
if !server.setConnection(connection) {
|
|
return
|
|
}
|
|
connection.SetReadLimit(MaxControlMessageBytes)
|
|
|
|
helloContext, cancelHello := context.WithTimeout(server.ctx, helloTimeout)
|
|
messageType, payload, err := connection.Read(helloContext)
|
|
cancelHello()
|
|
if err != nil || messageType != websocket.MessageText {
|
|
closeControl(connection, "hello frame required")
|
|
return
|
|
}
|
|
message, err := decodeRequest(payload)
|
|
if err != nil {
|
|
closeControl(connection, "invalid control message")
|
|
return
|
|
}
|
|
if validateHello(message) != nil {
|
|
closeControl(connection, "hello required")
|
|
return
|
|
}
|
|
var writeMutex sync.Mutex
|
|
write := func(ctx context.Context, value any) error {
|
|
writeMutex.Lock()
|
|
defer writeMutex.Unlock()
|
|
if event, ok := value.(ControlEvent); ok {
|
|
if event.Current != nil && !event.Current() {
|
|
return nil
|
|
}
|
|
value = event.Value
|
|
}
|
|
encoded, encodeErr := encodeMessage(value)
|
|
if encodeErr != nil {
|
|
return encodeErr
|
|
}
|
|
return connection.Write(ctx, websocket.MessageText, encoded)
|
|
}
|
|
if err = write(server.ctx, controlMessage{
|
|
Version: ProtocolVersion, ID: message.ID, Type: "ready",
|
|
}); err != nil {
|
|
return
|
|
}
|
|
controlContext, cancelControl := context.WithCancel(server.ctx)
|
|
defer cancelControl()
|
|
var extension ControlSession
|
|
if server.newControl != nil {
|
|
extension = server.newControl()
|
|
}
|
|
if extension != nil {
|
|
defer extension.Close()
|
|
go func() {
|
|
for {
|
|
select {
|
|
case event, open := <-extension.Events():
|
|
if !open {
|
|
return
|
|
}
|
|
if write(controlContext, event) != nil {
|
|
cancelControl()
|
|
return
|
|
}
|
|
case <-controlContext.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
reads := make(chan []byte, controlQueueSize)
|
|
go func() {
|
|
for {
|
|
messageType, payload, readErr := connection.Read(controlContext)
|
|
if readErr != nil || messageType != websocket.MessageText {
|
|
cancelControl()
|
|
return
|
|
}
|
|
select {
|
|
case reads <- payload:
|
|
case <-controlContext.Done():
|
|
return
|
|
default:
|
|
cancelControl()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
for {
|
|
select {
|
|
case <-controlContext.Done():
|
|
return
|
|
case payload = <-reads:
|
|
messageType = websocket.MessageText
|
|
}
|
|
message, err = decodeEnvelope(payload)
|
|
if err != nil {
|
|
closeControl(connection, "invalid control message")
|
|
return
|
|
}
|
|
if message.Type == "ping" {
|
|
strict, strictErr := decodeRequest(payload)
|
|
if strictErr != nil || validatePing(strict) != nil ||
|
|
write(controlContext, controlMessage{
|
|
Version: ProtocolVersion, ID: message.ID, Type: "pong",
|
|
}) != nil {
|
|
closeControl(connection, "invalid control message")
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if extension == nil {
|
|
closeControl(connection, "unsupported control message")
|
|
return
|
|
}
|
|
value, handleErr := extension.Handle(controlContext, payload)
|
|
if handleErr != nil {
|
|
closeControl(connection, "invalid control message")
|
|
return
|
|
}
|
|
if value == nil {
|
|
continue
|
|
}
|
|
if err = write(controlContext, value); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (server *Server) originPatterns() []string {
|
|
server.mu.Lock()
|
|
origins := append([]string(nil), server.allowedOrigins...)
|
|
server.mu.Unlock()
|
|
patterns := make([]string, 0, len(origins)+2)
|
|
for _, origin := range origins {
|
|
if parsed, err := url.Parse(origin); err == nil && parsed.Host != "" {
|
|
patterns = append(patterns, parsed.Scheme+"://"+parsed.Host)
|
|
}
|
|
}
|
|
port := strconv.Itoa(server.endpoint.Port)
|
|
patterns = append(patterns, "http://localhost:"+port, "http://127.0.0.1:"+port)
|
|
return patterns
|
|
}
|
|
|
|
func closeControl(connection *websocket.Conn, reason string) {
|
|
_ = connection.Close(websocket.StatusPolicyViolation, reason)
|
|
}
|
|
|
|
func (server *Server) requestAllowed(response http.ResponseWriter, request *http.Request) bool {
|
|
origin := strings.TrimSpace(request.Header.Get("Origin"))
|
|
if origin != "" && !server.allowedOrigin(origin) && !server.localOrigin(origin) {
|
|
http.Error(response, "forbidden", http.StatusForbidden)
|
|
return false
|
|
}
|
|
if !server.hostAllowed(request.Host) {
|
|
http.NotFound(response, request)
|
|
return false
|
|
}
|
|
if origin != "" {
|
|
response.Header().Set("Access-Control-Allow-Origin", origin)
|
|
response.Header().Add("Vary", "Origin")
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (server *Server) allowedOrigin(origin string) bool {
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
for _, allowed := range server.allowedOrigins {
|
|
if sameOrigin(origin, allowed) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (server *Server) hostAllowed(host string) bool {
|
|
if host == server.endpoint.Host {
|
|
return true
|
|
}
|
|
return host == net.JoinHostPort("localhost", strconv.Itoa(server.endpoint.Port))
|
|
}
|
|
|
|
func (server *Server) localOrigin(origin string) bool {
|
|
parsed, err := url.Parse(origin)
|
|
return err == nil && parsed.Scheme == "http" &&
|
|
parsed.Port() == strconv.Itoa(server.endpoint.Port) &&
|
|
(strings.EqualFold(parsed.Hostname(), "localhost") ||
|
|
strings.EqualFold(parsed.Hostname(), "127.0.0.1"))
|
|
}
|
|
|
|
func (server *Server) claimConnection() bool {
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if server.closed || server.claimed >= maxControlSessions {
|
|
return false
|
|
}
|
|
server.claimed++
|
|
server.controls.Add(1)
|
|
return true
|
|
}
|
|
|
|
func (server *Server) setConnection(connection *websocket.Conn) bool {
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if server.closed {
|
|
return false
|
|
}
|
|
server.connections[connection] = struct{}{}
|
|
return true
|
|
}
|
|
|
|
func (server *Server) releaseConnection(connection *websocket.Conn) {
|
|
server.mu.Lock()
|
|
delete(server.connections, connection)
|
|
server.claimed--
|
|
server.mu.Unlock()
|
|
server.controls.Done()
|
|
}
|
|
|
|
func listenInRange(bindAddress string, start, end int) (net.Listener, error) {
|
|
var lastErr error
|
|
for port := start; port <= end; port++ {
|
|
listener, err := net.Listen("tcp4", net.JoinHostPort(bindAddress, strconv.Itoa(port)))
|
|
if err == nil {
|
|
return listener, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("helper could not bind port range %d-%d: %w", start, end, lastErr)
|
|
}
|
|
|
|
func normalizePortRange(start, end int) (int, int) {
|
|
if start <= 0 && end <= 0 {
|
|
return DefaultPortStart, DefaultPortEnd
|
|
}
|
|
if start <= 0 {
|
|
start = end
|
|
}
|
|
if start < 1 {
|
|
start = 1
|
|
}
|
|
if start > 65535 {
|
|
start = 65535
|
|
}
|
|
if end < start {
|
|
end = start
|
|
}
|
|
if end > 65535 {
|
|
end = 65535
|
|
}
|
|
return start, end
|
|
}
|
|
|
|
func sameOrigin(actual, expected string) bool {
|
|
return strings.EqualFold(strings.TrimRight(actual, "/"), strings.TrimRight(expected, "/"))
|
|
}
|
|
|
|
func normalizedOrigins(origins []string) []string {
|
|
result := make([]string, 0, len(origins))
|
|
seen := make(map[string]struct{}, len(origins))
|
|
for _, origin := range origins {
|
|
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
|
if origin == "" {
|
|
continue
|
|
}
|
|
if _, found := seen[strings.ToLower(origin)]; found {
|
|
continue
|
|
}
|
|
seen[strings.ToLower(origin)] = struct{}{}
|
|
result = append(result, origin)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func newInstanceToken() (string, error) {
|
|
buffer := make([]byte, instanceTokenBytes)
|
|
if _, err := rand.Read(buffer); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
|
}
|
|
|
|
func writeJSON(response http.ResponseWriter, status int, value any) {
|
|
payload, err := encodeMessage(value)
|
|
if err != nil {
|
|
http.Error(response, "response unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
response.WriteHeader(status)
|
|
_, _ = response.Write(payload)
|
|
}
|