Files
Piik/tests/browser-encoding-pool.test.ts
T
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

231 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserEncodingPool } from "../src/client/media/browser-encoding-pool";
import { BrowserEncodingProducer } from "../src/client/media/browser-encoding-producer";
import type { BrowserEncodingOutput } from "../src/client/media/browser-encoding-output";
import { QUALITY_PROFILES } from "../src/client/media/quality";
const codec = { mimeType: "video/VP8", clockRate: 90_000 };
const profile = QUALITY_PROFILES["1080p30"];
const source = { kind: "video", readyState: "live", enabled: true, id: "source" } as MediaStreamTrack;
const producers = new Map<BrowserEncodingProducer, { budget: number; bitrate: number; bytes: number; frames: number }>();
let pool: BrowserEncodingPool;
function stats(row: Record<string, unknown>): RTCStatsReport {
return new Map([
["video", { id: "video", timestamp: Date.now(), type: "outbound-rtp", kind: "video", codecId: "codec", ...row }],
["codec", { id: "codec", timestamp: Date.now(), type: "codec", ...codec }],
]) as unknown as RTCStatsReport;
}
function member(budget = 2_000_000) {
const demand = { budget };
let producerId: string | null = null;
const select = vi.fn((id: string, _requestKey: () => Promise<void>, selected: () => void) => {
producerId = id;
selected();
});
const encoded = {
select, push: vi.fn(), passthrough: vi.fn(), setPaused: vi.fn(),
snapshot: () => ({ frames: producerId ? 30 : 0, width: 1920, height: 1080, timestamp: Date.now(), lastProducerId: producerId }),
} as unknown as BrowserEncodingOutput;
const connection = { connectionState: "connected", getStats: async () => stats({ targetBitrate: demand.budget }) };
const fatal = vi.fn();
const handle = pool.create(source, {} as RTCRtpSender, connection as RTCPeerConnection, profile,
encoded, async () => true, async () => undefined, fatal)!;
return { demand, select, fatal, handle, producerId: () => producerId };
}
async function sample(count = 1) {
await vi.advanceTimersByTimeAsync(500 * count);
}
function holdNextProducerStart(): () => void {
let ready!: () => void;
const start = vi.mocked(BrowserEncodingProducer.prototype.start).getMockImplementation()!;
vi.mocked(BrowserEncodingProducer.prototype.start).mockImplementationOnce(async function (this: BrowserEncodingProducer, budget) {
await start.call(this, budget);
await new Promise<void>((resolve) => { ready = resolve; });
});
return () => ready();
}
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("RTCRtpSender", { getCapabilities: () => ({ codecs: [codec] }) });
producers.clear();
vi.spyOn(BrowserEncodingProducer.prototype, "start").mockImplementation(async function (this: BrowserEncodingProducer, budget) {
producers.set(this, { budget: budget!, bitrate: budget! / 2, bytes: 0, frames: 0 });
});
vi.spyOn(BrowserEncodingProducer.prototype, "report").mockImplementation(async function (this: BrowserEncodingProducer) {
const output = producers.get(this)!;
output.bytes += output.bitrate / 16;
output.frames += 15;
return stats({ bytesSent: output.bytes, framesEncoded: output.frames, frameWidth: 1920, frameHeight: 1080,
qualityLimitationReason: "none" });
});
vi.spyOn(BrowserEncodingProducer.prototype, "update").mockImplementation(async function (this: BrowserEncodingProducer, _profile, budget) {
producers.get(this)!.budget = budget!;
});
vi.spyOn(BrowserEncodingProducer.prototype, "setPaused").mockImplementation(() => undefined);
vi.spyOn(BrowserEncodingProducer.prototype, "dispose").mockImplementation(() => undefined);
pool = new BrowserEncodingPool();
});
afterEach(() => {
pool.dispose();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("Browser encoding pool ownership", () => {
it("pauses a shared producer only when all of its members pause", async () => {
const first = member(), second = member();
await sample(4);
expect(producers.size).toBe(1);
const producer = [...producers.keys()][0]!;
first.handle.setPaused(true);
expect(producer.setPaused).toHaveBeenLastCalledWith(false);
second.handle.setPaused(true);
expect(producer.setPaused).toHaveBeenLastCalledWith(true);
first.handle.setPaused(false);
expect(producer.setPaused).toHaveBeenLastCalledWith(false);
expect(first.producerId()).toBe(second.producerId());
expect(producer.dispose).not.toHaveBeenCalled();
});
it("selects a prepared producer without waiting for another budget sample", async () => {
const ready = holdNextProducerStart();
const first = member();
await sample();
expect(first.select).not.toHaveBeenCalled();
ready();
await vi.advanceTimersByTimeAsync(0);
expect(first.producerId()).toBe([...producers.keys()][0]!.id);
});
it("keeps a shared encoder through a bitrate spike with unchanged native demand", async () => {
const first = member(), second = member();
await sample(4);
expect(producers.size).toBe(1);
const [producer, output] = [...producers][0];
expect(first.producerId()).toBe(producer.id);
expect(second.producerId()).toBe(producer.id);
output.bitrate = output.budget + 16;
await sample();
output.bitrate = output.budget / 2;
await sample(2);
expect(producers.size).toBe(1);
expect(first.select).toHaveBeenCalledTimes(1);
expect(second.select).toHaveBeenCalledTimes(1);
expect(producer.dispose).not.toHaveBeenCalled();
});
it("updates shared demand in place when all children need a lower rate", async () => {
const first = member(), second = member();
await sample(4);
const [producer, output] = [...producers][0];
first.demand.budget = second.demand.budget = 500_000;
await sample(2);
expect(producers.size).toBe(1);
expect(output.budget).toBe(500_000);
expect(producer.update).toHaveBeenCalledWith(profile, 500_000);
expect(first.select).toHaveBeenCalledTimes(1);
expect(second.select).toHaveBeenCalledTimes(1);
});
it("gives a weaker child its own rate owner and reunites it after recovery", async () => {
const strong = member(), weak = member();
await sample(4);
const [original, output] = [...producers][0];
weak.demand.budget = 500_000;
await sample(3);
expect(producers.size).toBe(2);
expect(strong.producerId()).toBe(original.id);
expect(weak.producerId()).not.toBe(original.id);
expect(output.budget).toBe(2_000_000);
expect(strong.select).toHaveBeenCalledTimes(1);
weak.demand.budget = 2_000_000;
await sample(3);
expect(weak.producerId()).toBe(original.id);
expect(producers.size).toBe(2);
expect(strong.fatal).not.toHaveBeenCalled();
expect(weak.fatal).not.toHaveBeenCalled();
});
it("starts protection at committed output, ignoring retired selection callbacks", async () => {
const begin = vi.spyOn(BrowserEncodingProducer.prototype, "beginOutput");
const first = member();
let selected: (() => void) | undefined;
first.select.mockImplementation((_id, _requestKey, commit) => { selected = commit; });
await sample(3);
expect(selected).toBeDefined();
expect(begin).not.toHaveBeenCalled();
selected!();
expect(begin).toHaveBeenCalledOnce();
const late = member();
let retired: (() => void) | undefined;
late.select.mockImplementation((_id, _requestKey, commit) => { retired = commit; });
await sample(2);
expect(retired).toBeDefined();
late.handle.dispose();
retired!();
expect(begin).toHaveBeenCalledOnce();
});
it("rechecks a ready handoff after applying a falling budget, without another stats tick", async () => {
const strong = member(), weak = member();
await sample(4);
const original = [...producers.keys()][0]!;
const ready = holdNextProducerStart();
weak.demand.budget = 500_000;
await sample();
const candidate = [...producers.keys()][1]!;
let complete!: () => void;
vi.mocked(candidate.update).mockImplementationOnce(async (_profile, budget) => {
await new Promise<void>((resolve) => { complete = resolve; });
producers.get(candidate)!.budget = budget;
});
weak.demand.budget = 100_000;
await sample();
ready();
await vi.advanceTimersByTimeAsync(0);
expect(candidate.update).toHaveBeenCalledWith(profile, 100_000);
expect(weak.producerId()).toBe(original.id);
complete();
await vi.advanceTimersByTimeAsync(0);
expect(weak.producerId()).toBe(candidate.id);
expect(strong.producerId()).toBe(original.id);
expect(producers.get(original)!.budget).toBe(2_000_000);
});
it.each(["failed", "retired"])("does not install a %s budget update's candidate", async (outcome) => {
member();
const weak = member();
await sample(4);
const ready = holdNextProducerStart();
weak.demand.budget = 500_000;
await sample();
const candidate = [...producers.keys()][1]!;
let resolve!: () => void, reject!: (error: Error) => void;
vi.mocked(candidate.update).mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
weak.demand.budget = 100_000;
await sample();
ready();
await vi.advanceTimersByTimeAsync(0);
const selections = weak.select.mock.calls.length;
if (outcome === "retired") { weak.handle.dispose(); resolve(); }
else reject(new Error("encoder settings failed"));
await vi.advanceTimersByTimeAsync(0);
expect(weak.select).toHaveBeenCalledTimes(selections);
expect(weak.fatal).not.toHaveBeenCalled();
});
});