Files
Piik/internal/app/loopback/server_test.go
T
AAA ff50e0609e fix(media): preserve playback through native connection recovery
## 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.
2026-09-20 09:45:55 +08:00

582 lines
19 KiB
Go

package loopback
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
const testOrigin = "https://share.example.com"
func TestStartServesHealthAndStrictControlHandshake(t *testing.T) {
expectedMedia := NativeMediaCapabilities{
Video: true, ProcessAudio: false, SystemAudio: true, HardwareH264: true,
}
server := startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
NativeMedia: expectedMedia,
})
endpoint := server.Endpoint()
var health Health
response := doHealthRequest(t, endpoint, endpoint.Host, testOrigin)
if response.StatusCode != http.StatusOK {
t.Fatalf("health status = %d", response.StatusCode)
}
if err := json.NewDecoder(response.Body).Decode(&health); err != nil {
t.Fatal(err)
}
response.Body.Close()
if health.Protocol != ProtocolVersion || health.Service != ServiceName ||
health.Port != endpoint.Port || health.InstanceToken != endpoint.InstanceToken ||
health.NativeMedia != expectedMedia {
t.Fatalf("health = %+v", health)
}
connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
if ready.Type != "ready" || ready.ID != "request_hello" {
t.Fatalf("ready = %+v", ready)
}
writeControl(t, connection, requestJSON("request_ping", "ping"))
var pong controlMessage
readControl(t, connection, &pong)
if pong.Type != "pong" || pong.ID != "request_ping" {
t.Fatalf("pong = %+v", pong)
}
}
func TestHealthRejectsUnexpectedOriginAndHost(t *testing.T) {
server := startTestServer(t, testOrigin)
endpoint := server.Endpoint()
response := doHealthRequest(t, endpoint, endpoint.Host, "https://other.example")
response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("bad origin status = %d", response.StatusCode)
}
response = doHealthRequest(t, endpoint, "127.0.0.2:"+strconv.Itoa(endpoint.Port), testOrigin)
response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("bad host status = %d", response.StatusCode)
}
}
func TestHealthAcceptsTheLocalHostAlias(t *testing.T) {
server := startTestServer(t, testOrigin)
endpoint := server.Endpoint()
response := doHealthRequest(t, endpoint,
"localhost:"+strconv.Itoa(endpoint.Port),
"http://localhost:"+strconv.Itoa(endpoint.Port),
)
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("localhost health status = %d", response.StatusCode)
}
}
func TestPresentationUpdatesOnlyLanguageWithoutClaimingMediaControl(t *testing.T) {
languages := make(chan string, 3)
var server *Server
server = startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
Presentation: func(language string) {
server.SetAllowedOrigins([]string{testOrigin})
languages <- language
},
})
endpoint := server.Endpoint()
connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
client := &http.Client{Timeout: time.Second}
for _, input := range []struct {
method, path, origin, body, language string
status int
}{
{http.MethodPost, "/presentation", testOrigin, `{"language":"zh"}`, "zh", http.StatusNoContent},
{http.MethodPost, "/presentation", testOrigin, `{"language":"en"}`, "en", http.StatusNoContent},
{http.MethodPost, "/presentation", endpoint.URL, `{"language":"vis"}`, "vis", http.StatusNoContent},
{http.MethodOptions, "/presentation", testOrigin, "", "", http.StatusNoContent},
{http.MethodPost, "/presentation", "https://other.example", `{"language":"en"}`, "", http.StatusForbidden},
{http.MethodOptions, "/presentation", "https://other.example", "", "", http.StatusForbidden},
{http.MethodOptions, "/health", testOrigin, "", "", http.StatusNoContent},
{http.MethodOptions, "/health", "https://other.example", "", "", http.StatusForbidden},
{http.MethodGet, "/presentation", testOrigin, "", "", http.StatusMethodNotAllowed},
{http.MethodPost, "/presentation", testOrigin, `{"language":"other"}`, "", http.StatusBadRequest},
{http.MethodPost, "/presentation", testOrigin, `{"language":"en","capture":true}`, "", http.StatusBadRequest},
{http.MethodPost, "/presentation", testOrigin, `{"language":"en"}{}`, "", http.StatusBadRequest},
{http.MethodPost, "/presentation", testOrigin, strings.Repeat(" ", 257) + `{"language":"en"}`, "", http.StatusBadRequest},
} {
request, err := http.NewRequest(input.method, endpoint.URL+input.path, strings.NewReader(input.body))
if err != nil {
t.Fatal(err)
}
request.Header.Set("Origin", input.origin)
request.Header.Set("Content-Type", "application/json")
if input.method == http.MethodOptions {
request.Header.Set("Access-Control-Request-Private-Network", "true")
}
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != input.status {
t.Fatalf("%s %s status = %d, want %d", input.method, input.path, response.StatusCode, input.status)
}
if input.method == http.MethodOptions && input.status == http.StatusNoContent &&
(response.Header.Get("Access-Control-Allow-Origin") != testOrigin ||
response.Header.Get("Access-Control-Allow-Private-Network") != "true" ||
(input.path == "/presentation" && (response.Header.Get("Access-Control-Allow-Methods") != "POST" ||
response.Header.Get("Access-Control-Allow-Headers") != "Content-Type")) ||
(input.path == "/health" && response.Header.Get("Access-Control-Allow-Methods") != "GET")) {
t.Fatal("presentation preflight did not preserve its bounded origin/method scope")
}
select {
case language := <-languages:
if language != input.language {
t.Fatalf("unexpected presentation callback: %q", language)
}
default:
if input.language != "" {
t.Fatal("presentation callback missing")
}
}
}
writeControl(t, connection, requestJSON("request_ping", "ping"))
var pong controlMessage
readControl(t, connection, &pong)
if pong.Type != "pong" {
t.Fatal("presentation changed the active media control")
}
}
func TestControlSessionsAreBoundedAndRetireIndependently(t *testing.T) {
created := make(chan *testControlSession, maxControlSessions)
server := startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
NewControl: func() ControlSession {
session := &testControlSession{events: make(chan any), closed: make(chan struct{})}
created <- session
return session
},
})
endpoint := server.Endpoint()
var connections []*websocket.Conn
var sessions []*testControlSession
for index := 0; index < maxControlSessions; index++ {
connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
connections = append(connections, connection)
select {
case session := <-created:
sessions = append(sessions, session)
case <-time.After(time.Second):
t.Fatal("accepted control did not acquire its own session")
}
}
extra, response, err := websocket.Dial(context.Background(), websocketURL(endpoint)+"/control", &websocket.DialOptions{
HTTPHeader: http.Header{"Origin": []string{testOrigin}},
Subprotocols: []string{ControlSubprotocol + "." + endpoint.InstanceToken},
})
if extra != nil {
extra.CloseNow()
}
if err == nil || response == nil || response.StatusCode != http.StatusConflict {
t.Fatalf("excess control dial: conn=%v response=%v err=%v", extra, response, err)
}
_ = response.Body.Close()
_ = connections[0].CloseNow()
select {
case <-sessions[0].closed:
case <-time.After(time.Second):
t.Fatal("closed room retained its native session")
}
writeControl(t, connections[1], requestJSON("request_ping", "ping"))
var pong controlMessage
readControl(t, connections[1], &pong)
if pong.Type != "pong" {
t.Fatal("closing one room affected its sibling control")
}
if err = server.Close(); err != nil {
t.Fatal(err)
}
for _, session := range sessions {
select {
case <-session.closed:
default:
t.Fatal("App close returned before native session cleanup")
}
}
}
func TestControlSessionSharesOneBoundedSocketForResponsesAndEvents(t *testing.T) {
extension := &testControlSession{
events: make(chan any, 1),
closed: make(chan struct{}),
}
server := startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
NewControl: func() ControlSession {
return extension
},
})
connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin)
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
// The old owner's event was queued while valid, but retired before delivery.
current := true
retired := ControlEvent{Value: controlMessage{Version: ProtocolVersion, Type: "retired-event"},
Current: func() bool { return current }}
current = false
extension.events <- retired
extension.events <- ControlEvent{
Value: controlMessage{Version: ProtocolVersion, Type: "current-event"},
Current: func() bool { return true },
}
var currentEvent controlMessage
readControl(t, connection, &currentEvent)
if currentEvent.Type != "current-event" {
t.Fatalf("retired event escaped the delivery fence: %+v", currentEvent)
}
extension.events <- controlMessage{
Version: ProtocolVersion, ID: "event_123456", Type: "extension-event",
}
writeControl(t, connection, requestJSON("request_extension", "extension"))
received := map[string]bool{}
for len(received) < 2 {
var message controlMessage
readControl(t, connection, &message)
received[message.Type] = true
}
if !received["extension-event"] || !received["extension-response"] {
t.Fatalf("control messages = %+v", received)
}
connection.CloseNow()
select {
case <-extension.closed:
case <-time.After(time.Second):
t.Fatal("control extension was not closed with its socket")
}
}
func TestControlClosesTheSocketWhenItsReaderRejectsAFrame(t *testing.T) {
server := startTestServer(t, testOrigin)
connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := connection.Write(ctx, websocket.MessageBinary, []byte("invalid")); err != nil {
t.Fatal(err)
}
if _, _, err := connection.Read(ctx); err == nil || ctx.Err() != nil {
t.Fatalf("server did not close its rejected control socket: %v", err)
}
}
func TestControlCloseCancelsAHandlerWaitingForPicker(t *testing.T) {
extension := &blockingControlSession{
startedSignal: make(chan struct{}),
closedSignal: make(chan struct{}),
}
server := startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
NewControl: func() ControlSession {
return extension
},
})
connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin)
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
writeControl(t, connection, requestJSON("request_extension", "extension"))
select {
case <-extension.startedSignal:
case <-time.After(time.Second):
t.Fatal("control handler did not start")
}
connection.CloseNow()
select {
case <-extension.closedSignal:
case <-time.After(time.Second):
t.Fatal("closing the control socket did not release the picker handler")
}
deadline := time.Now().Add(time.Second)
for {
second, response, err := websocket.Dial(context.Background(),
websocketURL(server.Endpoint())+"/control", &websocket.DialOptions{
HTTPHeader: http.Header{"Origin": []string{testOrigin}},
Subprotocols: []string{ControlSubprotocol + "." + server.Endpoint().InstanceToken},
})
if err == nil {
second.CloseNow()
return
}
if response != nil && response.Body != nil {
response.Body.Close()
}
if time.Now().After(deadline) {
t.Fatalf("control session was not released: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestControlSessionProcessesARequestBurstInOrder(t *testing.T) {
extension := &testControlSession{
events: make(chan any, 1),
closed: make(chan struct{}),
}
server := startTestServerWithOptions(t, Options{
AllowedOrigins: []string{testOrigin},
NewControl: func() ControlSession {
return extension
},
})
connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var ready controlMessage
readControl(t, connection, &ready)
const burst = 32
for index := 0; index < burst; index++ {
writeControl(t, connection, requestJSON(
fmt.Sprintf("request_extension_%d", index), "extension",
))
}
seen := make(map[string]bool, burst)
for len(seen) < burst {
var response controlMessage
readControl(t, connection, &response)
seen[response.ID] = true
}
for index := 0; index < burst; index++ {
if !seen[fmt.Sprintf("request_extension_%d", index)] {
t.Fatalf("request %d was not processed", index)
}
}
}
func TestDeferredResponseKeepsControlResponsiveAndRequestIdentity(t *testing.T) {
extension := &testControlSession{events: make(chan any, 1), closed: make(chan struct{})}
server := startTestServerWithOptions(t, Options{AllowedOrigins: []string{testOrigin},
NewControl: func() ControlSession { return extension }})
connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin)
defer connection.CloseNow()
writeControl(t, connection, requestJSON("request_hello", "hello"))
var value controlMessage
readControl(t, connection, &value)
writeControl(t, connection, requestJSON("request_pending", "deferred"))
writeControl(t, connection, requestJSON("request_ping", "ping"))
readControl(t, connection, &value)
if value.Type != "pong" || value.ID != "request_ping" {
t.Fatalf("pending operation blocked control: %+v", value)
}
extension.events <- controlMessage{Version: ProtocolVersion, ID: "request_pending", Type: "extension-response"}
readControl(t, connection, &value)
if value.Type != "extension-response" || value.ID != "request_pending" {
t.Fatalf("deferred response lost its request identity: %+v", value)
}
}
func TestStartAlwaysBindsToIPv4Loopback(t *testing.T) {
server := startTestServer(t, testOrigin)
host, _, err := net.SplitHostPort(server.Endpoint().Host)
if err != nil || host != "127.0.0.1" {
t.Fatalf("helper host = %q, %v", host, err)
}
}
func TestCloseStopsTheControlSession(t *testing.T) {
server := startTestServer(t, testOrigin)
endpoint := server.Endpoint()
connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin)
if err := server.Close(); err != nil {
t.Fatal(err)
}
readContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, _, err := connection.Read(readContext); err == nil {
t.Fatal("control connection remained open after server close")
}
connection.CloseNow()
}
func TestUnexpectedServeFailureIsReported(t *testing.T) {
server := startTestServer(t, testOrigin)
if err := server.listener.Close(); err != nil {
t.Fatal(err)
}
select {
case err := <-server.Done():
if err == nil {
t.Fatal("unexpected listener close was reported as a clean shutdown")
}
case <-time.After(time.Second):
t.Fatal("serve failure was not reported")
}
}
func startTestServer(t *testing.T, origin string) *Server {
t.Helper()
return startTestServerWithOptions(t, Options{AllowedOrigins: []string{origin}})
}
func startTestServerWithOptions(t *testing.T, options Options) *Server {
t.Helper()
port := freePort(t)
options.PortStart = port
options.PortEnd = port
server, err := Start(context.Background(), options)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
return server
}
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func doHealthRequest(t *testing.T, endpoint Endpoint, host, origin string) *http.Response {
t.Helper()
request, err := http.NewRequest(http.MethodGet, endpoint.URL+"/health", nil)
if err != nil {
t.Fatal(err)
}
request.Host = host
if origin != "" {
request.Header.Set("Origin", origin)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
return response
}
func dialControl(t *testing.T, endpoint Endpoint, token, origin string) *websocket.Conn {
t.Helper()
connection, _, err := websocket.Dial(context.Background(), websocketURL(endpoint)+"/control", &websocket.DialOptions{
HTTPHeader: http.Header{"Origin": []string{origin}},
Subprotocols: []string{ControlSubprotocol + "." + token},
})
if err != nil {
t.Fatal(err)
}
return connection
}
func websocketURL(endpoint Endpoint) string {
return strings.Replace(endpoint.URL, "http://", "ws://", 1)
}
func writeControl(t *testing.T, connection *websocket.Conn, value map[string]any) {
t.Helper()
if err := wsjson.Write(context.Background(), connection, value); err != nil {
t.Fatal(err)
}
}
func readControl(t *testing.T, connection *websocket.Conn, value any) {
t.Helper()
contextWithDeadline, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := wsjson.Read(contextWithDeadline, connection, value); err != nil {
t.Fatal(err)
}
}
func requestJSON(id, messageType string) map[string]any {
return map[string]any{
"version": ProtocolVersion,
"id": id,
"type": messageType,
}
}
type testControlSession struct {
events chan any
closed chan struct{}
}
type blockingControlSession struct {
started sync.Once
closed sync.Once
startedSignal chan struct{}
closedSignal chan struct{}
}
func (session *blockingControlSession) Handle(ctx context.Context, _ []byte) (any, error) {
session.started.Do(func() { close(session.startedSignal) })
<-ctx.Done()
return nil, ctx.Err()
}
func (session *blockingControlSession) Events() <-chan any {
return nil
}
func (session *blockingControlSession) Close() error {
session.closed.Do(func() { close(session.closedSignal) })
return nil
}
func (session *testControlSession) Handle(_ context.Context, payload []byte) (any, error) {
message, err := decodeEnvelope(payload)
if err == nil && message.Type == "deferred" {
return nil, nil
}
if err != nil || message.Type != "extension" {
return nil, errors.New("unexpected extension request")
}
return controlMessage{
Version: ProtocolVersion, ID: message.ID, Type: "extension-response",
}, nil
}
func (session *testControlSession) Events() <-chan any {
return session.events
}
func (session *testControlSession) Close() error {
select {
case <-session.closed:
default:
close(session.closed)
}
return nil
}