## 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.
231 lines
9.4 KiB
TypeScript
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();
|
|
});
|
|
});
|