Files
AAA 28827ec6a2 fix(media): improve sharing continuity and pause recovery (#431)
## Problem and outcome / 问题与结果

Sharing could stall at Browser encoder handoff or retain a pause after a video binding was replaced. Source/audio changes could also overlap offer/answer negotiation, while Native recovery could request redundant keyframes. This phase repairs those existing owners, drains capture output during bounded shutdown, and normalizes configured Site origins consistently with Browser serialization.

## Release notes

### 中文

这是一次稳定性更新,重点改善分享中的画面连续性,以及暂停、换源和恢复播放。

- 修复浏览器编码复用中的重复帧率限制和交接等待,减少由这些问题造成的卡顿。
- 修复换源和音频变更时的协商重叠,并防止已关闭连接的异步操作干扰恢复。
- 修复房主暂停期间重连后无法恢复播放,以及编码复用回退后仍停在暂停状态的问题;正常取消播放不再被当作媒体故障。
- 减少原生采集的重复关键帧请求,并修复停止采集时输出未排空导致的退出等待问题。
- 修复部分国际化域名和 IPv6 站点地址的识别,并改善窄屏下分享设置项的文字排版。

升级 Piik App 或自部署 Server 后刷新页面即可,无需修改配置。部分设备和网络环境下报告的开播失败、偶发断流仍需现场日志确认,本次更新不代表所有此类反馈均已解决。

### English

This stability update improves picture continuity and recovery when pausing, changing sources, or resuming playback.

- Fix duplicate frame-rate limiting and unnecessary handoff waits in Browser encoding reuse, reducing stalls caused by these issues.
- Serialize negotiation across source and audio changes, and prevent asynchronous work on closed connections from interfering with recovery.
- Fix playback remaining paused after reconnecting while the Host is paused, or after falling back from shared encoding. Normal playback cancellation is no longer treated as a media failure.
- Avoid redundant Native capture keyframe requests and drain pending capture output during bounded shutdown.
- Fix recognition of some internationalized domain names and IPv6 Site addresses, and improve sharing-setting labels on narrow screens.

Update Piik App or your self-hosted Server, then refresh the page. No configuration changes are required. Some device- and network-specific reports of sharing startup failure or intermittent stream loss still need matching diagnostics; this update does not claim to resolve every reported case.

## Verification / 验证

- `npm run check`: 76 test files / 1,152 tests, TypeScript and production Web build passed.
- Real Chrome VP8/H264 checks with two viewers passed live/paused profile changes, quiet-source recovery, source replacement and ordinary-sender fallback after producer failure.
- New pause-owner regressions fail against the previous implementation; actual Viewer effects preserve same-binding local pause and resume replacement bindings.
- Independent cumulative Browser and Native/config review found no confirmed release blocker.
- `npm run check:go`: formatting, Go tests/vet, Windows/Linux builds and Windows native capture compile/probes passed. Darwin packaging and Go race detection run on the native CI runners.
- Windows App H264 and VP8 Host gates passed decoded playback, live/paused quality changes, quiet/background-source recovery, source replacement, failure cleanup and subsequent sharing; experiment-owned processes, ports and profiles were cleaned up.
- Documentation, repository hygiene and release-note extraction passed.

## Impact / 影响

Patch release v1.6.7. Browser/server v23, Native control v9, capture v7 and SQLite schema 2 remain unchanged. Existing WebRTC adaptation and route policy remain unchanged; no quality floor is introduced. Interaction and Windows x86 prototypes are excluded. Managed Server restart interrupts active media and preserves room authority. Physical macOS/Linux capture, broad public-network endurance and unmatched reporter environments remain outside local acceptance.
2026-09-27 17:14:28 +08:00

309 lines
12 KiB
Go

package nativecapture
import (
"bytes"
"encoding/binary"
"errors"
"io"
"slices"
"testing"
"time"
)
func TestReadFrameUsesTheBoundedVersionedEnvelope(t *testing.T) {
payload := []byte{0, 0, 0, 1, 0x65}
header := make([]byte, envelopeHeaderBytes)
copy(header, "SMED")
header[4] = 2
header[5] = byte(FrameH264)
header[6] = 1
header[7] = 1
binary.BigEndian.PutUint64(header[8:16], 123)
binary.BigEndian.PutUint64(header[16:24], 333_333)
binary.BigEndian.PutUint16(header[24:26], 1920)
binary.BigEndian.PutUint16(header[26:28], 1080)
binary.BigEndian.PutUint32(header[28:32], uint32(len(payload)))
frame, err := readFrame(bytes.NewReader(append(header, payload...)))
if err != nil {
t.Fatal(err)
}
if frame.Kind != FrameH264 || !frame.KeyFrame || frame.Layer != 1 || frame.Width != 1920 || frame.Height != 1080 ||
frame.Timestamp != 12_300*time.Nanosecond ||
frame.Duration != 33_333_300*time.Nanosecond ||
!bytes.Equal(frame.Data, payload) {
t.Fatalf("frame = %+v", frame)
}
header[5] = byte(FrameVP8)
frame, err = readFrame(bytes.NewReader(append(header, payload...)))
if err != nil || frame.Kind != FrameVP8 || !frame.KeyFrame || !bytes.Equal(frame.Data, payload) {
t.Fatalf("VP8 frame = %+v, %v", frame, err)
}
}
func TestReadFrameRejectsInvalidKindsFlagsAndBounds(t *testing.T) {
base := make([]byte, envelopeHeaderBytes+1)
copy(base, "SMED")
base[4] = 2
base[5] = byte(FrameH264)
binary.BigEndian.PutUint64(base[16:24], 1)
binary.BigEndian.PutUint16(base[24:26], 1280)
binary.BigEndian.PutUint16(base[26:28], 720)
binary.BigEndian.PutUint32(base[28:32], 1)
for _, mutate := range []func([]byte){
func(value []byte) { value[4] = 1 },
func(value []byte) { value[5] = 9 },
func(value []byte) { value[6] = 2 },
func(value []byte) { value[7] = maxOutputs },
func(value []byte) { binary.BigEndian.PutUint16(value[24:26], 1279) },
func(value []byte) { binary.BigEndian.PutUint64(value[8:16], ^uint64(0)) },
func(value []byte) { binary.BigEndian.PutUint32(value[28:32], maxMediaBytes+1) },
} {
candidate := append([]byte(nil), base...)
mutate(candidate)
if _, err := readFrame(bytes.NewReader(candidate)); err == nil {
t.Fatal("invalid native frame was accepted")
}
}
}
func TestMediaEnvelopeAcceptsFourMiBAndRejectsLargerFrames(t *testing.T) {
const limit = 4 * 1024 * 1024
frame := Frame{Kind: FrameH264, KeyFrame: true, Width: 2560, Height: 1440,
Duration: time.Second / 60, Data: make([]byte, limit)}
var output bytes.Buffer
if err := writeFrame(&output, frame); err != nil {
t.Fatal(err)
}
read, err := readFrame(bytes.NewReader(output.Bytes()))
if err != nil || len(read.Data) != limit || read.Width != frame.Width || read.Height != frame.Height {
t.Fatalf("4 MiB media envelope rejected: bytes=%d err=%v", len(read.Data), err)
}
frame.Data = append(frame.Data, 0)
var rejected bytes.Buffer
if err := writeFrame(&rejected, frame); err == nil || rejected.Len() != 0 {
t.Fatal("oversized media wrote an envelope")
}
header := append([]byte(nil), output.Bytes()[:envelopeHeaderBytes]...)
binary.BigEndian.PutUint32(header[28:32], limit+1)
if _, err := readFrame(bytes.NewReader(header)); err == nil || errors.Is(err, io.EOF) {
t.Fatal("oversized input was not rejected before payload allocation")
}
}
func TestInputBeginAndUnavailableLayerHaveDistinctEnvelopes(t *testing.T) {
header := make([]byte, envelopeHeaderBytes)
copy(header, "SMED")
header[4], header[5] = 2, byte(FrameBegin)
binary.BigEndian.PutUint64(header[16:24], 333_333)
frame, err := readFrame(bytes.NewReader(header))
if err != nil || frame.Kind != FrameBegin || len(frame.Data) != 0 || frame.Duration <= 0 {
t.Fatalf("begin = %+v, %v", frame, err)
}
header[5], header[7] = byte(FrameLayerUnavailable), 1
binary.BigEndian.PutUint64(header[16:24], 0)
payload := []byte("output encoder unavailable")
binary.BigEndian.PutUint32(header[28:32], uint32(len(payload)))
frame, err = readFrame(bytes.NewReader(append(header, payload...)))
if err != nil || frame.Kind != FrameLayerUnavailable || frame.Layer != 1 || !bytes.Equal(frame.Data, payload) {
t.Fatalf("unavailable = %+v, %v", frame, err)
}
binary.BigEndian.PutUint32(header[28:32], 1)
if _, err = readFrame(bytes.NewReader(append(header, 0xff))); err == nil {
t.Fatal("non-UTF8 output diagnostic was accepted")
}
for _, invalid := range []Frame{
{Kind: FrameControl, Data: bytes.Repeat([]byte{'Q'}, 65)},
{Kind: FrameControl, Data: []byte("Q\n")},
{Kind: FrameControl, Data: []byte("Q"), Layer: 1},
{Kind: FrameControl, Data: []byte("Q"), Timestamp: time.Second},
{Kind: FrameControl, Data: []byte("Q"), KeyFrame: true},
} {
var output bytes.Buffer
if err := writeFrame(&output, invalid); err == nil || output.Len() != 0 {
t.Fatal("invalid control wrote a partial envelope")
}
}
}
func TestOutputProfilesAndControlFramesShareOneBoundedContract(t *testing.T) {
profile := VideoProfile{Width: 854, Height: 480, Framerate: 30, Bitrate: 2_000_000, Preference: "balanced"}
outputs := ScreenShareOutputs(profile)
if !slices.Equal(outputs, []OutputProfile{
{Width: 426, Height: 240, Framerate: 30, Bitrate: 500_000},
{Width: 854, Height: 480, Framerate: 30, Bitrate: 2_000_000},
}) || !outputs[0].Valid() || !outputs[1].Valid() {
t.Fatalf("derived outputs = %+v", outputs)
}
derived := []OutputProfile{{Width: 160, Height: 90, Framerate: 30, Bitrate: 90_000}, {Width: 320, Height: 180, Framerate: 30, Bitrate: 300_000}}
if _, err := appendOutputArguments([]string{"--protocol-v7"}, derived); err != nil {
t.Fatal(err)
}
for _, slots := range [][]OutputProfile{{derived[1], derived[0]}, {derived[0], derived[0]},
{derived[0], derived[1], derived[0], derived[0], derived[0], derived[0]}} {
if _, err := appendOutputArguments(nil, slots); err != nil {
t.Fatalf("independent output slots rejected: %v", err)
}
}
for _, invalid := range [][]OutputProfile{nil, make([]OutputProfile, maxOutputs+1), {{}}} {
if _, err := appendOutputArguments(nil, invalid); err == nil {
t.Fatal("invalid derived output set was accepted")
}
}
reader, writer := io.Pipe()
done := make(chan error)
close(done)
stream := &Stream{input: io.NopCloser(bytes.NewReader(nil)), key: writer, done: done, outputs: outputs, cancel: func() {}}
control := make(chan []byte, 1)
go func() {
payload, _ := io.ReadAll(reader)
_ = reader.Close()
control <- payload
}()
for _, command := range []func() error{
func() error { return stream.SetOutputActive(1, true) },
func() error { return stream.SetOutputActive(1, true) },
func() error { return stream.SetOutputActive(0, true) },
func() error { return stream.SetOutputActive(1, false) },
func() error { return stream.SetOutputActive(1, false) },
func() error { return stream.SetOutputBitrate(0, 250_000) },
func() error { return stream.SetOutputBitrate(0, 250_000) },
func() error { return stream.RequestKeyFrame(0) },
func() error { return stream.RequestKeyFrame(-1) },
func() error {
return stream.WriteFrame(Frame{Kind: FrameVP8, Layer: 0, Width: 160, Height: 90, Timestamp: time.Second, Duration: 33_333_300 * time.Nanosecond, KeyFrame: true, Data: []byte{1, 2, 3}})
},
stream.Close,
} {
if err := command(); err != nil {
t.Fatal(err)
}
}
frames := bytes.NewReader(<-control)
var commands []string
var encodedInputs int
for frames.Len() > 0 {
frame, err := readFrame(frames)
if err != nil {
t.Fatal(err)
}
if frame.Kind == FrameControl {
commands = append(commands, string(frame.Data))
continue
}
if frame.Kind != FrameVP8 || !frame.KeyFrame || frame.Width != 160 || frame.Height != 90 || frame.Timestamp != time.Second || !bytes.Equal(frame.Data, []byte{1, 2, 3}) {
t.Fatalf("encoded input = %+v", frame)
}
encodedInputs++
}
if !slices.Equal(commands, []string{"A 1 1", "A 0 1", "A 1 0", "B 0 250000", "K 0", "K -1", "Q"}) || encodedInputs != 1 {
t.Fatalf("controls = %q, encoded inputs = %d", commands, encodedInputs)
}
if !errors.Is(stream.RequestKeyFrame(0), io.ErrClosedPipe) || stream.SetOutputActive(2, true) == nil ||
stream.SetOutputActive(-1, false) == nil || !errors.Is(stream.SetOutputActive(0, false), io.ErrClosedPipe) {
t.Fatal("closed or invalid controls were accepted")
}
}
func TestIndependentOutputEnvelopeUsesTheLastBoundedSlot(t *testing.T) {
var output bytes.Buffer
frame := Frame{Kind: FrameVP8, Layer: maxOutputs - 1, Width: 320, Height: 180,
Timestamp: time.Second, Duration: time.Second / 30, Data: []byte{1}}
if err := writeFrame(&output, frame); err != nil {
t.Fatal(err)
}
decoded, err := readFrame(&output)
if err != nil || decoded.Layer != maxOutputs-1 {
t.Fatalf("last output slot = %+v, %v", decoded, err)
}
}
func TestSourceListRejectsPrivateProtocolDrift(t *testing.T) {
var targets []CaptureTarget
if err := decodeStrictJSON([]byte(
`[{"kind":"window","sourceId":"1","pid":2,"creationTime":"3","title":"Game","extra":true}]`,
), &targets); err == nil {
t.Fatal("unknown source-list field was accepted")
}
}
func TestAudioReadyStateIsStrictAndExplicit(t *testing.T) {
if err := validateAudioReadyFrame(Frame{
Kind: FrameStatus,
Data: []byte(`{"state":"active","audio":true}`),
}); err != nil {
t.Fatal(err)
}
for _, payload := range []string{
`{"state":"active","audio":false}`,
`{"state":"starting","audio":true}`,
`{"state":"active","audio":true,"extra":true}`,
} {
if err := validateAudioReadyFrame(Frame{
Kind: FrameStatus,
Data: []byte(payload),
}); err == nil {
t.Fatalf("invalid audio state was accepted: %s", payload)
}
}
if err := validateAudioReadyFrame(Frame{
Kind: FramePCM,
Data: []byte(`{"state":"active","audio":true}`),
}); err == nil {
t.Fatal("audio PCM was accepted as a ready frame")
}
}
func TestCaptureTargetIdentityAndAudioScope(t *testing.T) {
window := CaptureTarget{
Kind: "window", SourceID: "123", PID: 42,
CreationTime: "456", Title: "Game",
}
display := CaptureTarget{Kind: "display", SourceID: "789", Title: "Display 1"}
picker := CaptureTarget{Kind: "picker", SourceID: "1", Title: "System picker"}
if !window.Valid() || !display.Valid() ||
!picker.Valid() {
t.Fatal("valid capture targets were rejected")
}
for _, invalid := range []CaptureTarget{
{Kind: "window", SourceID: "123", PID: 42, Title: "Game"},
{Kind: "display", SourceID: "789", PID: 42, Title: "Display 1"},
{Kind: "display", SourceID: "789", CreationTime: "456", Title: "Display 1"},
{Kind: "picker", SourceID: "1", PID: 42, Title: "System picker"},
{Kind: "other", SourceID: "789", Title: "Display 1"},
} {
if invalid.Valid() {
t.Fatalf("invalid capture target was accepted: %+v", invalid)
}
}
audio := Summary{ProcessAudio: true, SystemAudio: true}
windowWithoutProcess := Summary{SystemAudio: true}
if !audio.AudioFor("window") || !audio.AudioFor("display") ||
!audio.AudioFor("picker") ||
windowWithoutProcess.AudioFor("window") {
t.Fatal("capture audio scope was not separated by target kind")
}
}
func TestVideoProfileUsesTheProductBounds(t *testing.T) {
for _, profile := range []VideoProfile{
{Width: 854, Height: 480, Framerate: 15, Bitrate: 2_000_000, Preference: "maintain-resolution"},
{Width: 1280, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 60, Bitrate: 8_000_000, Preference: "maintain-framerate"},
{Width: 2560, Height: 1440, Framerate: 60, Bitrate: 12_000_000, Preference: "balanced"},
} {
if !profile.Valid() {
t.Fatalf("valid profile rejected: %+v", profile)
}
}
for _, profile := range []VideoProfile{
{Width: 1920, Height: 1200, Framerate: 30, Bitrate: 5_000_000, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 14, Bitrate: 5_000_000, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 30, Bitrate: 12_000_001, Preference: "balanced"},
{Width: 1920, Height: 1080, Framerate: 30, Bitrate: 5_000_000, Preference: "unknown"},
} {
if profile.Valid() {
t.Fatalf("invalid profile accepted: %+v", profile)
}
}
}