## Problem and outcome / ????? Windows Auto could reject startup because it benchmarked the only remaining VP8 path after hardware encoding failed. Start that ordinary fallback directly; keep comparison only when it can choose between usable encoders. Browser peer construction failures now reach their existing preparation/recovery owners, while constructors release tracks they acquired before throwing. Native event timeout text no longer assumes which encoder event was missing. ## Release notes ### ?? ?????????????????? - ?? Windows ????????????????????????????????????? - ????????????????????????????????????? - ?????????????????????????????? ???????????????????????????????????? ### English This patch improves sharing startup and connection recovery. - Fixes Windows automatic codec selection failing to start when an unnecessary software benchmark fails after hardware encoding is unavailable. - Fixes some browser media-connection initialization failures not reaching the existing failure or recovery flow. - Releases video tracks left behind by failed initialization and clarifies native encoder timeout diagnostics. These fixes passed local validation. Device- or network-specific sharing failures still require evidence from the affected environment. ## Verification / ?? - npm run check: 76 test files / 1,115 tests, TypeScript and production Web build passed. - Windows capture build and deterministic native checks passed, including proved-H264 retention and software-only fallback under a failed/expired optional comparison. The old implementation fails the new fallback check. - Fault-injected hardware-unavailable and exhausted-probe-budget paths both captured a synthetic window and produced decoded 1920?1080 VP8 frames, followed by clean process/port/profile retirement. - Browser regressions cover construction failures, exact connection identity, existing recovery budgets, prepared-candidate replacement and owned-track cleanup. Ordinary Host rebuild keeps the existing Viewer recovery owner. - Documentation and repository hygiene checks passed. npm run check:go-core passed; Darwin cgo remains assigned to the native macOS packaging runner. CI also verifies race checks and all platform packages. ## Impact / ?? Compatible patch release v1.6.5; no wire, storage, room authority, retry-policy or quality-adaptation change. Source diagnostics stay ignored and private. The complete v1.6.3 reporter exports narrow failure stages but do not establish the affected machine's root cause. No interaction or room-voice feature is included. A matching Server restart interrupts process-owned media; routine atomic deployment keeps the pre-cutover binary through bounded postflight.
1051 lines
34 KiB
TypeScript
1051 lines
34 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { IceConfig, SignalPayload } from "../src/shared/protocol.ts";
|
|
import type { PeerSnapshot } from "../src/client/types.ts";
|
|
import { ViewerPeer } from "../src/client/webrtc/viewer-peer.ts";
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
}
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
const ANSWER_SDP = [
|
|
"v=0",
|
|
"o=- 1 1 IN IP4 127.0.0.1",
|
|
"s=-",
|
|
"t=0 0",
|
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111",
|
|
"c=IN IP4 0.0.0.0",
|
|
"a=rtpmap:111 opus/48000/2",
|
|
"a=fmtp:111 minptime=10;useinbandfec=1",
|
|
"a=recvonly",
|
|
"",
|
|
].join("\r\n");
|
|
|
|
interface ConnectionPlan {
|
|
constructorError?: Error;
|
|
answerError?: Error;
|
|
candidateGates?: Promise<void>[];
|
|
localDescriptionGate?: Promise<void>;
|
|
statsGate?: Promise<RTCStatsReport>;
|
|
}
|
|
|
|
class FakeMediaStream {
|
|
private readonly tracks: MediaStreamTrack[] = [];
|
|
|
|
getTrackById(id: string): MediaStreamTrack | null {
|
|
return this.tracks.find((track) => track.id === id) ?? null;
|
|
}
|
|
|
|
addTrack(track: MediaStreamTrack): void {
|
|
this.tracks.push(track);
|
|
}
|
|
|
|
getVideoTracks(): MediaStreamTrack[] {
|
|
return this.tracks.filter((track) => track.kind === "video");
|
|
}
|
|
|
|
getAudioTracks(): MediaStreamTrack[] {
|
|
return this.tracks.filter((track) => track.kind === "audio");
|
|
}
|
|
}
|
|
|
|
class FakePeerConnection extends EventTarget {
|
|
static readonly instances: FakePeerConnection[] = [];
|
|
static readonly plans: ConnectionPlan[] = [];
|
|
|
|
readonly configurations: RTCConfiguration[] = [];
|
|
connectionState: RTCPeerConnectionState = "new";
|
|
iceConnectionState: RTCIceConnectionState = "new";
|
|
iceGatheringState: RTCIceGatheringState = "new";
|
|
remoteDescription: RTCSessionDescription | null = null;
|
|
localDescription: RTCSessionDescription | null = null;
|
|
statsGate: Promise<RTCStatsReport> | null = null;
|
|
|
|
private readonly candidateGates: Promise<void>[];
|
|
private readonly localDescriptionGate: Promise<void> | null;
|
|
private readonly answerError: Error | null;
|
|
|
|
readonly addIceCandidate = vi.fn(async (_candidate: RTCIceCandidateInit | null) => {
|
|
const gate = this.candidateGates.shift();
|
|
if (gate) {
|
|
await gate;
|
|
}
|
|
});
|
|
readonly getStats = vi.fn(async () => {
|
|
if (this.statsGate) {
|
|
return this.statsGate;
|
|
}
|
|
return new Map() as unknown as RTCStatsReport;
|
|
});
|
|
readonly setLocalDescription = vi.fn(
|
|
async (description: RTCSessionDescriptionInit) => {
|
|
if (this.localDescriptionGate) {
|
|
await this.localDescriptionGate;
|
|
}
|
|
this.localDescription = description as RTCSessionDescription;
|
|
},
|
|
);
|
|
|
|
constructor(configuration?: RTCConfiguration) {
|
|
super();
|
|
if (configuration) {
|
|
this.configurations.push(configuration);
|
|
}
|
|
const plan = FakePeerConnection.plans.shift() ?? {};
|
|
if (plan.constructorError) throw plan.constructorError;
|
|
this.candidateGates = [...(plan.candidateGates ?? [])];
|
|
this.localDescriptionGate = plan.localDescriptionGate ?? null;
|
|
this.answerError = plan.answerError ?? null;
|
|
this.statsGate = plan.statsGate ?? null;
|
|
FakePeerConnection.instances.push(this);
|
|
}
|
|
|
|
readonly setRemoteDescription = vi.fn(
|
|
async (description: RTCSessionDescriptionInit): Promise<void> => {
|
|
this.remoteDescription = description as RTCSessionDescription;
|
|
},
|
|
);
|
|
|
|
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
|
if (this.answerError) {
|
|
throw this.answerError;
|
|
}
|
|
return {
|
|
type: "answer",
|
|
sdp: ANSWER_SDP,
|
|
};
|
|
}
|
|
|
|
readonly setConfiguration = vi.fn((configuration: RTCConfiguration) => {
|
|
this.configurations.push(configuration);
|
|
});
|
|
|
|
close(): void {
|
|
this.connectionState = "closed";
|
|
}
|
|
|
|
emitTrack(track: MediaStreamTrack, streams: MediaStream[] = []): void {
|
|
this.dispatchEvent(
|
|
Object.assign(new Event("track"), { track, streams }),
|
|
);
|
|
}
|
|
}
|
|
|
|
const intervalCallbacks = new Map<number, () => void>();
|
|
let nextIntervalId = 1;
|
|
const timeoutCallbacks = new Map<number, () => void>();
|
|
const timeoutDelays = new Map<number, number>();
|
|
let nextTimeoutId = 1;
|
|
|
|
function offer(connectionId: string): SignalPayload {
|
|
return {
|
|
kind: "description",
|
|
connectionId,
|
|
description: { type: "offer", sdp: `offer-${connectionId}` },
|
|
};
|
|
}
|
|
|
|
function candidate(connectionId: string, value: string): SignalPayload {
|
|
return {
|
|
kind: "candidate",
|
|
connectionId,
|
|
candidate: {
|
|
candidate: value,
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function decodedReport(framesDecoded: number): RTCStatsReport {
|
|
return new Map([
|
|
[
|
|
"video-in",
|
|
{
|
|
id: "video-in",
|
|
type: "inbound-rtp",
|
|
timestamp: 1_000,
|
|
kind: "video",
|
|
framesDecoded,
|
|
},
|
|
],
|
|
]) as unknown as RTCStatsReport;
|
|
}
|
|
|
|
function createPeer(
|
|
signals: SignalPayload[],
|
|
snapshots: PeerSnapshot[],
|
|
signalPeers: string[] = [],
|
|
iceConfig: IceConfig = { iceServers: [] },
|
|
natPrediction = false,
|
|
): ViewerPeer {
|
|
return new ViewerPeer(
|
|
iceConfig,
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signalPeers.push(peerId);
|
|
signals.push(payload);
|
|
return true;
|
|
},
|
|
sendRestartRequest: () => true,
|
|
onStream: () => undefined,
|
|
onUpdate: (snapshot) => snapshots.push(snapshot),
|
|
},
|
|
{ natPredictionEnabled: natPrediction },
|
|
);
|
|
}
|
|
|
|
async function flushAsyncWork(): Promise<void> {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
FakePeerConnection.instances.length = 0;
|
|
FakePeerConnection.plans.length = 0;
|
|
intervalCallbacks.clear();
|
|
nextIntervalId = 1;
|
|
timeoutCallbacks.clear();
|
|
timeoutDelays.clear();
|
|
nextTimeoutId = 1;
|
|
vi.stubGlobal("MediaStream", FakeMediaStream);
|
|
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
|
vi.stubGlobal("window", {
|
|
setInterval: (callback: () => void) => {
|
|
const id = nextIntervalId++;
|
|
intervalCallbacks.set(id, callback);
|
|
return id;
|
|
},
|
|
clearInterval: (id: number) => intervalCallbacks.delete(id),
|
|
setTimeout: (callback: () => void, delay: number) => {
|
|
const id = nextTimeoutId++;
|
|
timeoutCallbacks.set(id, callback);
|
|
timeoutDelays.set(id, delay);
|
|
return id;
|
|
},
|
|
clearTimeout: (id: number) => {
|
|
timeoutCallbacks.delete(id);
|
|
timeoutDelays.delete(id);
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ViewerPeer connection generations", () => {
|
|
it.each(["route", "viewer"] as const)("handles construction failure with the %s recovery owner and exact identity", async (recoveryOwner) => {
|
|
const sendSignal = vi.fn(() => true);
|
|
const sendRestartRequest = vi.fn(() => true);
|
|
const exhausted = vi.fn((_parent: string, id: string) => {
|
|
expect(peer.hasConnectionId(id)).toBe(true);
|
|
return true;
|
|
});
|
|
const peer = new ViewerPeer({ iceServers: [] }, { sendSignal, sendRestartRequest,
|
|
onStream: () => undefined, onUpdate: () => undefined, onRecoveryExhausted: exhausted },
|
|
{ recoveryOwner });
|
|
FakePeerConnection.plans.push({ constructorError: new DOMException("Allocation failed", "UnknownError") });
|
|
await expect(peer.acceptSignal("host", offer("first"))).resolves.toBeUndefined();
|
|
expect(peer.hasConnection()).toBe(false);
|
|
expect(peer.getConnectionIdentity()).toEqual({ parentPeerId: "host", connectionId: "first" });
|
|
if (recoveryOwner === "route") {
|
|
expect(sendRestartRequest).not.toHaveBeenCalled();
|
|
expect(exhausted).toHaveBeenCalledExactlyOnceWith("host", "first");
|
|
} else {
|
|
expect(sendRestartRequest).toHaveBeenCalledExactlyOnceWith("host", "first", true);
|
|
FakePeerConnection.plans.push({ constructorError: new DOMException("Allocation failed", "UnknownError") });
|
|
await peer.acceptSignal("host", offer("second"));
|
|
expect(sendRestartRequest).toHaveBeenCalledOnce();
|
|
expect(exhausted).toHaveBeenCalledExactlyOnceWith("host", "second");
|
|
}
|
|
expect(timeoutCallbacks.size).toBe(0);
|
|
await peer.acceptSignal("host", offer("working"));
|
|
expect(sendSignal).toHaveBeenCalledOnce();
|
|
expect(peer.hasConnectionId("working")).toBe(true);
|
|
expect(peer.hasConnectionId("first")).toBe(false);
|
|
peer.dispose();
|
|
expect(peer.hasConnectionId("working")).toBe(false);
|
|
});
|
|
|
|
it("returns failed prepared SDP to its route owner without requesting a rebuild", async () => {
|
|
const restart = vi.fn(() => true), exhausted = vi.fn(() => true);
|
|
const peer = new ViewerPeer({ iceServers: [] }, { sendSignal: () => true, sendRestartRequest: restart,
|
|
onStream: () => undefined, onUpdate: () => undefined, onRecoveryExhausted: exhausted },
|
|
{ recoveryOwner: "route" });
|
|
FakePeerConnection.plans.push({ answerError: new Error("answer failed") });
|
|
await peer.acceptSignal("host", offer("prepared"));
|
|
expect(restart).not.toHaveBeenCalled();
|
|
expect(exhausted).toHaveBeenCalledExactlyOnceWith("host", "prepared");
|
|
expect(timeoutCallbacks.size).toBe(0);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("answers after a rejected queued candidate and still applies later candidates", async () => {
|
|
const signals: SignalPayload[] = [];
|
|
const snapshots: PeerSnapshot[] = [];
|
|
const peer = createPeer(signals, snapshots);
|
|
await peer.acceptSignal("host", candidate("queued", "stale-candidate"));
|
|
const current = candidate("queued", "current-candidate");
|
|
await peer.acceptSignal("host", current);
|
|
const answering = peer.acceptSignal("host", offer("queued"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.addIceCandidate.mockRejectedValueOnce(new DOMException("Unknown ICE username fragment", "OperationError"));
|
|
await answering;
|
|
expect(connection.addIceCandidate).toHaveBeenCalledTimes(2);
|
|
expect(signals).toHaveLength(1);
|
|
expect(signals[0]).toMatchObject({ kind: "description", description: { type: "answer" } });
|
|
expect(snapshots.at(-1)?.error).toBeNull();
|
|
peer.dispose();
|
|
});
|
|
|
|
it("keeps separately delivered audio and video on one remote stream", async () => {
|
|
const streams: MediaStream[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: () => true,
|
|
onStream: (stream) => streams.push(stream),
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
await peer.acceptSignal("host", offer("separate-tracks"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
const video = { id: "remote-video", kind: "video" } as MediaStreamTrack;
|
|
const audio = { id: "remote-audio", kind: "audio" } as MediaStreamTrack;
|
|
connection.emitTrack(video, [{} as MediaStream]);
|
|
connection.emitTrack(audio, [{} as MediaStream]);
|
|
|
|
expect(streams).toHaveLength(2);
|
|
expect(streams[0]).toBe(streams[1]);
|
|
expect(streams[1]!.getVideoTracks()).toEqual([video]);
|
|
expect(streams[1]!.getAudioTracks()).toEqual([audio]);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("proves a fresh exact connection from its first cumulative decoded frame", async () => {
|
|
FakePeerConnection.plans.push({
|
|
statsGate: Promise.resolve(decodedReport(1)),
|
|
});
|
|
const decoded = vi.fn(() => true);
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: () => true,
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onFirstDecodedFrame: decoded,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("host", offer("fresh-exact"));
|
|
await flushAsyncWork();
|
|
|
|
expect(decoded).toHaveBeenCalledOnce();
|
|
expect(decoded).toHaveBeenCalledWith("fresh-exact");
|
|
peer.dispose();
|
|
});
|
|
|
|
it("does not publish an in-flight decoded proof from a replaced connection", async () => {
|
|
const oldStats = createDeferred<RTCStatsReport>();
|
|
FakePeerConnection.plans.push(
|
|
{ statsGate: oldStats.promise },
|
|
{ statsGate: Promise.resolve(new Map() as unknown as RTCStatsReport) },
|
|
);
|
|
const decoded = vi.fn(() => true);
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: () => true,
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onFirstDecodedFrame: decoded,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("host-a", offer("old-exact"));
|
|
await peer.acceptSignal("host-b", offer("new-exact"));
|
|
oldStats.resolve(decodedReport(3));
|
|
await flushAsyncWork();
|
|
|
|
expect(decoded).not.toHaveBeenCalled();
|
|
peer.dispose();
|
|
});
|
|
|
|
it("sets and signals stereo audio bitrate for every answer", async () => {
|
|
const signals: SignalPayload[] = [];
|
|
const peer = createPeer(signals, []);
|
|
|
|
await peer.acceptSignal("host", offer("screen-audio"));
|
|
await peer.acceptSignal("host", offer("screen-audio"));
|
|
await peer.acceptSignal("host", offer("screen-audio-rebuild"));
|
|
|
|
const answers = signals.filter((signal) => signal.kind === "description");
|
|
const localDescriptions = FakePeerConnection.instances.flatMap((connection) =>
|
|
connection.setLocalDescription.mock.calls.map(([description]) => description),
|
|
);
|
|
expect(answers).toHaveLength(3);
|
|
for (const [index, description] of localDescriptions.entries()) {
|
|
expect(description).toEqual(expect.objectContaining({
|
|
type: "answer",
|
|
sdp: expect.stringContaining(
|
|
"a=fmtp:111 minptime=10;useinbandfec=1;stereo=1;maxaveragebitrate=192000",
|
|
),
|
|
}));
|
|
expect(answers[index]?.description.sdp).toBe(description.sdp);
|
|
}
|
|
});
|
|
|
|
it("applies STUN-only ICE configuration at creation and update", async () => {
|
|
const peer = createPeer([], [], [], {
|
|
iceServers: [{ urls: ["stun:stun-a.example.test:3478"] }],
|
|
});
|
|
await peer.acceptSignal("host", offer("stun-config"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
|
|
peer.updateIceConfig({
|
|
iceServers: [{ urls: ["stun:stun-b.example.test:3478"] }],
|
|
});
|
|
|
|
expect(connection.configurations).toEqual([
|
|
{ iceServers: [{ urls: ["stun:stun-a.example.test:3478"] }] },
|
|
{ iceServers: [{ urls: ["stun:stun-b.example.test:3478"] }] },
|
|
]);
|
|
expect(connection.setConfiguration).toHaveBeenCalledOnce();
|
|
for (const configuration of connection.configurations) {
|
|
expect(
|
|
configuration.iceServers?.every(
|
|
(server) =>
|
|
!Reflect.has(server, "username") &&
|
|
!Reflect.has(server, "credential") &&
|
|
(typeof server.urls === "string"
|
|
? server.urls.startsWith("stun:")
|
|
: server.urls.every((url) => url.startsWith("stun:"))),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("uses the room NAT policy for Viewer-originated ICE candidates", async () => {
|
|
const signals: SignalPayload[] = [];
|
|
const peer = createPeer(
|
|
signals,
|
|
[],
|
|
[],
|
|
{
|
|
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
|
natPredictionStunUrls: [
|
|
"stun:share.example.test:3479",
|
|
"stun:share.example.test:3480",
|
|
],
|
|
},
|
|
true,
|
|
);
|
|
await peer.acceptSignal("host", offer("nat-room-policy"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
expect(connection.configurations[0]?.iceServers).toEqual([
|
|
{ urls: "stun:share.example.test:3478" },
|
|
{ urls: "stun:share.example.test:3479" },
|
|
{ urls: "stun:share.example.test:3480" },
|
|
]);
|
|
|
|
const surveyUrls = [
|
|
"stun:share.example.test:3478",
|
|
"stun:share.example.test:3479",
|
|
"stun:share.example.test:3480",
|
|
];
|
|
const emitCandidate = (port: number, url: string): void => {
|
|
const event = new Event("icecandidate");
|
|
Object.defineProperty(event, "candidate", {
|
|
value: {
|
|
candidate:
|
|
`candidate:base 1 udp 2122260223 203.0.113.7 ${port} ` +
|
|
"typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test",
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0,
|
|
usernameFragment: "test",
|
|
url,
|
|
},
|
|
});
|
|
connection.dispatchEvent(event);
|
|
};
|
|
emitCandidate(40_000, surveyUrls[0]!);
|
|
emitCandidate(40_003, surveyUrls[1]!);
|
|
emitCandidate(40_006, surveyUrls[2]!);
|
|
connection.iceGatheringState = "complete";
|
|
connection.dispatchEvent(new Event("icegatheringstatechange"));
|
|
|
|
const candidates = signals.filter(
|
|
(signal): signal is Extract<SignalPayload, { kind: "candidate" }> =>
|
|
signal.kind === "candidate",
|
|
);
|
|
expect(
|
|
candidates.some((signal) =>
|
|
signal.candidate?.candidate.startsWith("candidate:sp"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
candidates.filter((signal) =>
|
|
signal.candidate?.candidate.startsWith("candidate:base"),
|
|
),
|
|
).toHaveLength(3);
|
|
expect(candidates.filter((signal) => signal.candidate === null)).toHaveLength(1);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("restarts ICE when an answered initial connection stays stuck", async () => {
|
|
const restartRequests: Array<{
|
|
peerId: string;
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ peerId, connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("relay-parent", offer("stuck-connection"));
|
|
|
|
expect(timeoutDelays.get(1)).toBe(15_000);
|
|
timeoutCallbacks.get(1)!();
|
|
expect(restartRequests).toEqual([
|
|
{
|
|
peerId: "relay-parent",
|
|
connectionId: "stuck-connection",
|
|
rebuild: false,
|
|
},
|
|
]);
|
|
expect(timeoutDelays.get(2)).toBe(3_000);
|
|
});
|
|
|
|
it("returns NAT-enabled candidate failure to its route owner without local retries", async () => {
|
|
const restartRequests: string[] = [];
|
|
const exhausted: string[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (_peerId, connectionId) => {
|
|
restartRequests.push(connectionId);
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onRecoveryExhausted: (_peerId, connectionId) => {
|
|
exhausted.push(connectionId);
|
|
return true;
|
|
},
|
|
},
|
|
{ recoveryOwner: "route", natPredictionEnabled: true },
|
|
);
|
|
|
|
await peer.acceptSignal("host", offer("route-candidate"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.connectionState = "disconnected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
|
|
expect(timeoutDelays).toEqual(new Map());
|
|
expect(restartRequests).toEqual([]);
|
|
expect(exhausted).toEqual([]);
|
|
|
|
connection.connectionState = "failed";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(restartRequests).toEqual([]);
|
|
expect(timeoutDelays).toEqual(new Map());
|
|
expect(exhausted).toEqual(["route-candidate"]);
|
|
});
|
|
|
|
it("transfers recovery ownership when a prepared route fails during commit", async () => {
|
|
const restartRequests: Array<{
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (_peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
{ recoveryOwner: "route" },
|
|
);
|
|
|
|
await peer.acceptSignal("host", offer("committed-candidate"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.connectionState = "failed";
|
|
peer.activatePreparedRoute();
|
|
|
|
expect(restartRequests).toEqual([
|
|
{ connectionId: "committed-candidate", rebuild: false },
|
|
]);
|
|
expect([...timeoutDelays.values()]).toEqual([3_000]);
|
|
});
|
|
|
|
it("cancels the initial deadline after connecting", async () => {
|
|
const restartRequests: string[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (_peerId, connectionId) => {
|
|
restartRequests.push(connectionId);
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("host", offer("connected-generation"));
|
|
const initialDeadline = timeoutCallbacks.get(1)!;
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.connectionState = "connected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
|
|
expect(timeoutCallbacks.has(1)).toBe(false);
|
|
initialDeadline();
|
|
expect(restartRequests).toEqual([]);
|
|
});
|
|
|
|
it("ignores an initial deadline from an old connection generation", async () => {
|
|
const restartRequests: string[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (_peerId, connectionId) => {
|
|
restartRequests.push(connectionId);
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("host", offer("connection-old"));
|
|
const oldDeadline = timeoutCallbacks.get(1)!;
|
|
await peer.acceptSignal("host", offer("connection-new"));
|
|
|
|
expect(timeoutCallbacks.has(1)).toBe(false);
|
|
expect(timeoutDelays.get(2)).toBe(15_000);
|
|
oldDeadline();
|
|
expect(restartRequests).toEqual([]);
|
|
|
|
const currentConnection = FakePeerConnection.instances[1]!;
|
|
currentConnection.connectionState = "connected";
|
|
currentConnection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(timeoutCallbacks.has(2)).toBe(false);
|
|
});
|
|
|
|
it("clears the initial deadline when disposed", async () => {
|
|
const peer = createPeer([], []);
|
|
await peer.acceptSignal("host", offer("disposed-connection"));
|
|
|
|
peer.dispose();
|
|
|
|
expect(timeoutCallbacks).toEqual(new Map());
|
|
expect(timeoutDelays).toEqual(new Map());
|
|
});
|
|
|
|
it("reports one exhausted edge after bounded restart and rebuild attempts", async () => {
|
|
const restartRequests: Array<{
|
|
peerId: string;
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const exhausted: Array<{ peerId: string; connectionId: string }> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ peerId, connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onRecoveryExhausted: (peerId, connectionId) => {
|
|
exhausted.push({ peerId, connectionId });
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
await peer.acceptSignal("relay-parent", offer("failed-connection"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.connectionState = "failed";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
|
|
expect(restartRequests).toEqual([
|
|
{
|
|
peerId: "relay-parent",
|
|
connectionId: "failed-connection",
|
|
rebuild: false,
|
|
},
|
|
]);
|
|
timeoutCallbacks.get(2)!();
|
|
expect(restartRequests.at(-1)).toEqual({
|
|
peerId: "relay-parent",
|
|
connectionId: "failed-connection",
|
|
rebuild: true,
|
|
});
|
|
timeoutCallbacks.get(3)!();
|
|
expect(exhausted).toEqual([
|
|
{ peerId: "relay-parent", connectionId: "failed-connection" },
|
|
]);
|
|
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(restartRequests).toHaveLength(2);
|
|
expect(exhausted).toHaveLength(1);
|
|
});
|
|
|
|
it("retries delivery of an exhausted edge after signaling reconnects", async () => {
|
|
let reportAvailable = false;
|
|
const exhausted: string[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: () => true,
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onRecoveryExhausted: (_peerId, connectionId) => {
|
|
exhausted.push(connectionId);
|
|
return reportAvailable;
|
|
},
|
|
},
|
|
);
|
|
await peer.acceptSignal("relay-parent", offer("failed-connection"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
connection.connectionState = "failed";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
|
|
timeoutCallbacks.get(2)!();
|
|
timeoutCallbacks.get(3)!();
|
|
expect(exhausted).toEqual(["failed-connection"]);
|
|
|
|
reportAvailable = true;
|
|
timeoutCallbacks.get(4)!();
|
|
expect(exhausted).toEqual(["failed-connection", "failed-connection"]);
|
|
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(exhausted).toHaveLength(2);
|
|
});
|
|
|
|
it("requests one bounded rebuild when answer negotiation fails", async () => {
|
|
FakePeerConnection.plans.push(
|
|
{ answerError: new Error("first answer failed") },
|
|
{ answerError: new Error("second answer failed") },
|
|
);
|
|
const restartRequests: Array<{
|
|
peerId: string;
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ peerId, connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("relay-parent", offer("connection-first"));
|
|
await peer.acceptSignal("relay-parent", offer("connection-second"));
|
|
|
|
expect(restartRequests).toEqual([
|
|
{
|
|
peerId: "relay-parent",
|
|
connectionId: "connection-first",
|
|
rebuild: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("requests a rebuild when the completed answer cannot be signaled", async () => {
|
|
const restartRequests: Array<{
|
|
peerId: string;
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => false,
|
|
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ peerId, connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("relay-parent", offer("unsent-answer"));
|
|
|
|
expect(restartRequests).toEqual([
|
|
{
|
|
peerId: "relay-parent",
|
|
connectionId: "unsent-answer",
|
|
rebuild: true,
|
|
},
|
|
]);
|
|
expect([...timeoutDelays.values()]).toEqual([3_000]);
|
|
});
|
|
|
|
it("serializes recovery requests at the current parent", async () => {
|
|
const signals: SignalPayload[] = [];
|
|
const signalPeers: string[] = [];
|
|
const restartRequests: Array<{
|
|
peerId: string;
|
|
connectionId: string;
|
|
rebuild: boolean;
|
|
}> = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signalPeers.push(peerId);
|
|
signals.push(payload);
|
|
return true;
|
|
},
|
|
sendRestartRequest: (peerId, connectionId, rebuild) => {
|
|
restartRequests.push({ peerId, connectionId, rebuild });
|
|
return true;
|
|
},
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("parent-old", offer("connection-old"));
|
|
await peer.acceptSignal("parent-new", offer("connection-new"));
|
|
expect(signalPeers).toEqual(["parent-old", "parent-new"]);
|
|
|
|
expect(peer.requestRecovery(true)).toBe(true);
|
|
expect(peer.requestRecovery(true)).toBe(false);
|
|
expect(restartRequests).toEqual([
|
|
{
|
|
peerId: "parent-new",
|
|
connectionId: "connection-new",
|
|
rebuild: true,
|
|
},
|
|
]);
|
|
|
|
const connection = FakePeerConnection.instances.at(-1)!;
|
|
connection.connectionState = "connected";
|
|
[...timeoutCallbacks.values()].at(-1)!();
|
|
expect(peer.requestRecovery(true)).toBe(true);
|
|
expect(restartRequests).toHaveLength(2);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("retires the recovery deadline when a rebuilt connection is answered", async () => {
|
|
const exhausted: string[] = [];
|
|
const peer = new ViewerPeer(
|
|
{ iceServers: [] },
|
|
{
|
|
sendSignal: () => true,
|
|
sendRestartRequest: () => true,
|
|
onStream: () => undefined,
|
|
onUpdate: () => undefined,
|
|
onRecoveryExhausted: (_peerId, connectionId) => {
|
|
exhausted.push(connectionId);
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
|
|
await peer.acceptSignal("relay-parent", offer("connection-before-rebuild"));
|
|
expect(peer.requestRecovery(true)).toBe(true);
|
|
await peer.acceptSignal("relay-parent", offer("connection-after-rebuild"));
|
|
|
|
expect([...timeoutDelays.values()]).toEqual([15_000]);
|
|
expect(exhausted).toEqual([]);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("does not send an old answer or candidate after replacing the connection", async () => {
|
|
const oldLocalDescription = createDeferred<void>();
|
|
FakePeerConnection.plans.push({
|
|
localDescriptionGate: oldLocalDescription.promise,
|
|
});
|
|
const signals: SignalPayload[] = [];
|
|
const snapshots: PeerSnapshot[] = [];
|
|
const peer = createPeer(signals, snapshots);
|
|
|
|
const oldAccept = peer.acceptSignal("host", offer("connection-old"));
|
|
await vi.waitFor(() => {
|
|
expect(FakePeerConnection.instances[0]?.setLocalDescription).toHaveBeenCalledOnce();
|
|
});
|
|
const oldConnection = FakePeerConnection.instances[0]!;
|
|
|
|
await peer.acceptSignal("host", offer("connection-new"));
|
|
const iceEvent = new Event("icecandidate");
|
|
Object.defineProperty(iceEvent, "candidate", {
|
|
value: {
|
|
candidate: "candidate-from-old-connection",
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0,
|
|
usernameFragment: null,
|
|
},
|
|
});
|
|
oldConnection.dispatchEvent(iceEvent);
|
|
oldLocalDescription.resolve();
|
|
await oldAccept;
|
|
|
|
expect(signals).toHaveLength(1);
|
|
expect(signals[0]).toMatchObject({
|
|
kind: "description",
|
|
connectionId: "connection-new",
|
|
description: { type: "answer" },
|
|
});
|
|
expect(snapshots.at(-1)).toMatchObject({
|
|
connectionId: "connection-new",
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it("serializes ordinary offers for the same connection", async () => {
|
|
const firstLocalDescription = createDeferred<void>();
|
|
FakePeerConnection.plans.push({
|
|
localDescriptionGate: firstLocalDescription.promise,
|
|
});
|
|
const signals: SignalPayload[] = [];
|
|
const peer = createPeer(signals, []);
|
|
|
|
const first = peer.acceptSignal("host", offer("same-connection"));
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
FakePeerConnection.instances[0]?.setLocalDescription,
|
|
).toHaveBeenCalledOnce(),
|
|
);
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
const second = peer.acceptSignal("host", offer("same-connection"));
|
|
await Promise.resolve();
|
|
expect(connection.setRemoteDescription).toHaveBeenCalledOnce();
|
|
|
|
firstLocalDescription.resolve();
|
|
await Promise.all([first, second]);
|
|
expect(connection.setRemoteDescription).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
signals.filter((signal) => signal.kind === "description"),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it("stops flushing old candidates when the connection is replaced", async () => {
|
|
const firstCandidate = createDeferred<void>();
|
|
FakePeerConnection.plans.push({
|
|
candidateGates: [firstCandidate.promise],
|
|
});
|
|
const peer = createPeer([], []);
|
|
await peer.acceptSignal("host", candidate("connection-old", "candidate-1"));
|
|
await peer.acceptSignal("host", candidate("connection-old", "candidate-2"));
|
|
|
|
const oldAccept = peer.acceptSignal("host", offer("connection-old"));
|
|
await vi.waitFor(() => {
|
|
expect(FakePeerConnection.instances[0]?.addIceCandidate).toHaveBeenCalledOnce();
|
|
});
|
|
const oldConnection = FakePeerConnection.instances[0]!;
|
|
|
|
await peer.acceptSignal("host", offer("connection-new"));
|
|
const newConnection = FakePeerConnection.instances[1]!;
|
|
firstCandidate.resolve();
|
|
await oldAccept;
|
|
|
|
expect(oldConnection.addIceCandidate).toHaveBeenCalledTimes(1);
|
|
expect(newConnection.addIceCandidate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not apply stats that resolve after the connection is replaced", async () => {
|
|
const signals: SignalPayload[] = [];
|
|
const snapshots: PeerSnapshot[] = [];
|
|
const peer = createPeer(signals, snapshots);
|
|
await peer.acceptSignal("host", offer("connection-old"));
|
|
const oldConnection = FakePeerConnection.instances[0]!;
|
|
const oldStats = createDeferred<RTCStatsReport>();
|
|
oldConnection.statsGate = oldStats.promise;
|
|
const oldStatsCallback = intervalCallbacks.get(1)!;
|
|
|
|
oldStatsCallback();
|
|
await vi.waitFor(() => expect(oldConnection.getStats).toHaveBeenCalledOnce());
|
|
await peer.acceptSignal("host", offer("connection-new"));
|
|
const newSnapshotCount = snapshots.filter(
|
|
({ connectionId }) => connectionId === "connection-new",
|
|
).length;
|
|
|
|
oldStats.resolve(
|
|
new Map([
|
|
[
|
|
"inbound-old",
|
|
{
|
|
id: "inbound-old",
|
|
type: "inbound-rtp",
|
|
timestamp: 2_000,
|
|
kind: "video",
|
|
bytesReceived: 1_000,
|
|
framesDecoded: 60,
|
|
frameWidth: 1_920,
|
|
frameHeight: 1_080,
|
|
},
|
|
],
|
|
]) as unknown as RTCStatsReport,
|
|
);
|
|
await flushAsyncWork();
|
|
|
|
const newSnapshots = snapshots.filter(
|
|
({ connectionId }) => connectionId === "connection-new",
|
|
);
|
|
expect(newSnapshots).toHaveLength(newSnapshotCount);
|
|
expect(newSnapshots.at(-1)?.metrics.resolution).toBeNull();
|
|
});
|
|
|
|
it("skips overlapping stats ticks on the same connection", async () => {
|
|
const peer = createPeer([], []);
|
|
await peer.acceptSignal("host", offer("connection"));
|
|
const connection = FakePeerConnection.instances[0]!;
|
|
const stats = createDeferred<RTCStatsReport>();
|
|
connection.statsGate = stats.promise;
|
|
const statsCallback = intervalCallbacks.get(1)!;
|
|
|
|
statsCallback();
|
|
statsCallback();
|
|
await vi.waitFor(() => expect(connection.getStats).toHaveBeenCalledOnce());
|
|
|
|
stats.resolve(new Map() as unknown as RTCStatsReport);
|
|
await flushAsyncWork();
|
|
connection.statsGate = null;
|
|
statsCallback();
|
|
await vi.waitFor(() =>
|
|
expect(connection.getStats).toHaveBeenCalledTimes(2),
|
|
);
|
|
});
|
|
});
|