fix(media): recover sharing from initialization failures

## 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.
This commit is contained in:
AAA authored and GitHub committed 2026-09-25 17:08:42 +08:00
1 parent 383f3656c7
commit 04cbb2c9f4
14 files changed
+269 -90

No files matched your search

+3
View File
@@ -148,6 +148,9 @@ above rather than searching the tree.
Later cancellation must not erase a failure already observed before cleanup.
A caller deadline does not cancel a dependency that ignores it. Keep late
work bounded and serialize its cleanup; verify the dependency's actual lifetime.
Construction is part of acquisition: if it throws, release resources created
before the failure and report through the existing operation owner. A failed
allocation does not erase the operation's authoritative identity.
Ordered shutdown may require a ready auxiliary transport to outlive the stop
request; its owner closes it after dependants retire. External convenience
actions must not block lifecycle handling, and repeated attempts stay bounded.
+7 -4
View File
@@ -134,10 +134,13 @@ choice for quality/source replacement. Native relay encoding uses the same
selection owner. Auto measures encoding work
for synthetic NV12 frames at the selected dimensions and frame rate, with a
bounded warmup and sample. If H264 sustains the target it is selected; otherwise
VP8 is measured within the remaining four-second budget. An unsuccessful or
timed-out comparison retains an already-proved H264 encoder; without a proved
encoder, startup still fails. This is a throughput check, not a perceptual-quality
score or a promise under future GPU load.
a usable H264 candidate is compared with VP8 within the four-second budget.
An unsuccessful or timed-out comparison retains the proved H264 encoder. With
no usable H264 candidate, Auto starts the ordinary VP8 path directly, as manual
VP8 does; a benchmark with no alternative to compare must not gate capture.
Actual encoding and failure reporting remain owned by the capture worker. This
is a throughput comparison, not a perceptual-quality score or a promise under
future GPU load.
The returned actual codec owns the shared source, preview, and relay; live
quality/source changes retain it. Other native platform encoders remain H264.
+18 -14
View File
@@ -60,6 +60,12 @@ history. A parked idea is not implementation authority.
candidate-queue rejection defect is repaired and regression-tested; current
STUN reachability and route-lifecycle checks do not establish these reporters'
causes. HTTP 1033 belongs to the public-link item below, before media routing.
A complete v1.6.3 App report confirms four candidate timeouts without Native
share-start or any SDP/ICE signaling. Browser diagnostics are needed to
locate prepare delivery, peer initialization or the first offer send; these
attempts do not establish an ICE/NAT failure. Locally reproduced Browser
construction failures now release owned tracks and reach the existing
preparation/recovery owner, but are not proved to be this reporter's cause.
- [ ] **Camera and Host microphone device coverage.** The owner accepted the
sharing layout and authorized release with these physical limits recorded.
Verify real audio levels/echo, multiple-device replacement and native mixing on
@@ -101,20 +107,18 @@ history. A parked idea is not implementation authority.
machine's cause remains unconfirmed.
- [ ] **App discovery and share-start field acceptance.** Retest the missing-window
report, an unreachable App despite its process running, and generic share-start
failure with the current App and page. OS/browser, version, selected source and
paired Debug reports are still unavailable. Distinguish site authorization,
browser permission, control capacity, enumeration and capture-start failures;
local permission and recovery checks do not establish the reporters' causes.
Manual H264 returning immediately to idle also needs reporter diagnostics;
bounded selection past an unusable hardware encoder is locally verified.
Startup failure is reported again on the latest release without a known codec.
Auto selection now retains proved H264 if the optional VP8 comparison times
out; this confirmed selection defect does not establish the reporter's cause.
A separate v1.6.1 App/Chrome report lists Window/Screen sources, then waits
and returns to idle with a share-start error after selection. Its codec is
unknown; similarity to the H264 symptom does not establish the same cause.
Candidate rejection no longer aborts the local media bridge; validate on the
affected machine before attributing its startup failure to that defect.
failure. Distinguish site authorization, browser permission, control capacity,
enumeration and capture startup. A complete v1.6.3 Windows report confirms
Auto/Window/1080p30 balanced startup: hardware MFT event timeout, then software
probing, then cancellation because capture readiness did not arrive. The stall
within probing, teardown or WGC initialization remains unknown; one initial
VP8 statistics sample cannot establish its complete output history. Auto now
skips the redundant comparison when only VP8 remains. Injected unavailable
hardware and exhausted-probe-budget cases both capture and decode locally;
the affected machine still needs same-source Auto versus explicit VP8
acceptance. Do not extend deadlines without identifying the blocked stage.
Existing H264-retention/ICE-candidate repairs do not establish this cause; other
discovery/manual-H264 reports still need paired App/Browser diagnostics.
- [ ] **Share ends after entering a game.** Screen sharing reportedly works
until entering a game freezes the picture, followed seconds later by share
termination. Version, capture path, codec and matched diagnostics are unknown.
+1 -1
View File
@@ -717,7 +717,7 @@ MediaEventType LiveEncoder::NextEvent(std::chrono::steady_clock::time_point dead
if (type == MEError) Fail("mft-error-event", "hardware MFT emitted MEError");
return type;
}
Fail("mft-event-timeout", "hardware MFT did not request input in time");
Fail("mft-event-timeout", "hardware MFT event wait timed out");
}
void LiveEncoder::WaitForInput(EncoderClock::time_point probe_deadline) {
+4 -5
View File
@@ -1268,13 +1268,13 @@ struct VideoEncoderSelection final {
template <typename Measure>
VideoEncoderSelection CompareSoftwareEncoder(std::unique_ptr<VideoEncoder> hardware,
std::optional<double> hardware_work, EncoderClock::time_point deadline, Measure measure) {
// No comparison is needed when only the ordinary VP8 path remains.
if (!hardware_work) return {};
try {
RequireEncoderTime(deadline);
const double software_work = measure();
if (!hardware_work || software_work <= *hardware_work) return {};
} catch (const std::exception&) {
if (!hardware_work) throw;
}
if (software_work <= *hardware_work) return {};
} catch (const std::exception&) {}
// The deadline bounds new probing, not reuse of an already-proved encoder.
return {OutputKind::h264, std::move(hardware)};
}
@@ -1368,7 +1368,6 @@ VideoEncoderSelection SelectVideoEncoder(
device = DeviceContext{};
}
if (!device.device) {
RequireEncoderTime(deadline);
device = CreateDevice(SelectAdapter(adapters, arguments.adapter_index));
}
return CompareSoftwareEncoder(std::move(hardware), hardware_work, deadline, [&] {
+7 -12
View File
@@ -171,19 +171,14 @@ void CheckAutoEncoderFallback() {
assert((selected.kind == OutputKind::vp8) == (software_work <= .04));
assert(static_cast<bool>(selected.initial) == (software_work > .04));
}
const auto software_only = CompareSoftwareEncoder(nullptr, std::nullopt, deadline, [] { return .03; });
assert(software_only.kind == OutputKind::vp8 && !software_only.initial);
for (const auto probe_deadline : {deadline, expired}) {
bool failed = false;
try {
CompareSoftwareEncoder(nullptr, std::nullopt, probe_deadline, [&]() -> double {
RequireEncoderTime(expired);
return 0;
});
} catch (const GateFailure& error) {
failed = error.stage() == "codec-probe-timeout";
}
assert(failed); // No proved encoder must remain a startup failure.
bool probed = false;
const auto software_only = CompareSoftwareEncoder(nullptr, std::nullopt, probe_deadline, [&]() -> double {
probed = true;
throw GateFailure("codec-probe-timeout", "Optional comparison is unavailable");
});
// With no hardware candidate to compare, use the same VP8 path as manual selection.
assert(software_only.kind == OutputKind::vp8 && !software_only.initial && !probed);
}
}
+8 -2
View File
@@ -66,8 +66,14 @@ export class BrowserEncodingOutput {
// This synthetic clock needs WebRTC's screen-content ALR probing when the
// real producer sends little data. The producer owns picture adaptation.
this.track.contentHint = "detail";
const streams = encodedStreams(sender);
this.writer = streams.writable.getWriter();
let streams: ReturnType<typeof encodedStreams<RTCEncodedVideoFrame>>;
try {
streams = encodedStreams(sender);
this.writer = streams.writable.getWriter();
} catch (error) {
this.track.stop();
throw error;
}
void streams.readable.pipeTo(new WritableStream({ write: (frame) => this.receive(frame) }),
{ signal: this.abort.signal }).then(() => this.fail(), (error) => this.fail(error)).finally(() => {
try { this.writer.releaseLock(); } catch { /* Owner abort retired the writer. */ }
+10 -1
View File
@@ -5,6 +5,7 @@ import type {
SignalPayload,
} from "../../shared/protocol";
import { countEndpointMediaCopies } from "../../shared/media-copy-accounting";
import { debugError } from "../lib/debug";
import { HostPeer, type HostMediaPeer } from "../webrtc/host-peer";
import type { BrowserVideoCodecPreference } from "../webrtc/video-codec";
import type { PeerSnapshot } from "../types";
@@ -99,7 +100,15 @@ export class HostProvisionalChild {
return false;
}
if (input.candidate.transport === "direct") {
this.startPrepared(input.revision, input.candidate, input);
try {
this.startPrepared(input.revision, input.candidate, input);
} catch (error) {
debugError("webrtc", "sender-prepare-failed", error, {
connectionId: input.candidate.connectionId,
});
this.discard();
return false;
}
}
return true;
}
+13 -8
View File
@@ -129,14 +129,19 @@ export class HostPeer {
: null;
this.startupVideoProfilePending = needsStartupVideoProfile(desiredProfile);
this.encodedStreamsEnabled = !!videoPool && supportsBrowserEncoding();
this.connection = new RTCPeerConnection({
...(this.encodedStreamsEnabled ? { encodedInsertableStreams: true } : {}),
iceServers: iceServersWithNatPrediction(
iceConfig.iceServers,
this.natPredictionEnabled,
iceConfig.natPredictionStunUrls,
),
});
try {
this.connection = new RTCPeerConnection({
...(this.encodedStreamsEnabled ? { encodedInsertableStreams: true } : {}),
iceServers: iceServersWithNatPrediction(
iceConfig.iceServers,
this.natPredictionEnabled,
iceConfig.natPredictionStunUrls,
),
});
} catch (error) {
this.senderVideoTrack?.stop();
throw error;
}
observeDebugConnection(this.connection, { connectionId, peerId, role: "send" });
this.localIceCandidates = new NatPredictionCandidateEmitter(
this.natPredictionEnabled,
+23 -13
View File
@@ -124,7 +124,12 @@ export class ViewerPeer implements ViewerMediaPeer {
this.connectionId !== payload.connectionId ||
this.parentPeerId !== parentPeerId
) {
this.replaceConnection(parentPeerId, payload.connectionId);
try {
this.replaceConnection(parentPeerId, payload.connectionId);
} catch (error) {
this.failOffer(error, parentPeerId, payload.connectionId);
return;
}
}
const connection = this.connection;
if (!connection) {
@@ -196,7 +201,7 @@ export class ViewerPeer implements ViewerMediaPeer {
}
hasConnectionId(connectionId: string): boolean {
return this.connection !== null && this.connectionId === connectionId;
return this.connectionId === connectionId;
}
getConnectionIdentity(): {
@@ -394,17 +399,22 @@ export class ViewerPeer implements ViewerMediaPeer {
if (!this.isCurrentConnection(connection, connectionId)) {
return;
}
this.setError(error, "host.fail.connection");
if (
this.offerRecoveryAttempts < 1 &&
this.events.sendRestartRequest(parentPeerId, connectionId, true)
) {
this.offerRecoveryAttempts += 1;
this.automaticRecoveryRequests = VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS;
this.scheduleRecoveryDeadline();
} else {
this.reportRecoveryExhausted();
}
this.failOffer(error, parentPeerId, connectionId);
}
}
private failOffer(error: unknown, parentPeerId: string, connectionId: string): void {
this.setError(error, "host.fail.connection");
if (
this.recoveryOwner === "viewer" &&
this.offerRecoveryAttempts < 1 &&
this.events.sendRestartRequest(parentPeerId, connectionId, true)
) {
this.offerRecoveryAttempts += 1;
this.automaticRecoveryRequests = VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS;
this.scheduleRecoveryDeadline();
} else {
this.reportRecoveryExhausted();
}
}
+37 -30
View File
@@ -4,7 +4,7 @@ import type {
SignalPayload,
} from "../../shared/protocol";
import type { QualityProfile } from "../media/quality";
import { debugEvent } from "../lib/debug";
import { debugError, debugEvent } from "../lib/debug";
import { BrowserEncodingPool } from "../media/browser-encoding-pool";
import type { PeerSnapshot } from "../types";
import {
@@ -366,12 +366,20 @@ export class ViewerRelay {
candidate: PreparedRouteCandidate,
stream: MediaStream | null,
): void {
const peer = this.createPeer(
candidate.childPeerId,
stream,
candidate.connectionId,
candidate,
);
let peer: HostMediaPeer;
try {
peer = this.createPeer(
candidate.childPeerId,
stream,
candidate.connectionId,
candidate,
);
} catch (error) {
debugError("webrtc", "relay-prepare-failed", error, { connectionId: candidate.connectionId });
this.discardPreparedChild();
this.events.onPreparedChildFailed?.(revision, candidate.connectionId);
return;
}
this.preparedChild = {
revision,
childPeerId: candidate.childPeerId,
@@ -488,11 +496,11 @@ export class ViewerRelay {
}
}
private startPeer(
private async startPeer(
childPeerId: string,
stream: MediaStream | null,
attempt = 0,
): void {
): Promise<void> {
if (
this.disposed ||
this.peers.has(childPeerId) ||
@@ -502,27 +510,26 @@ export class ViewerRelay {
return;
}
const peer = this.createPeer(childPeerId, stream);
this.peers.set(childPeerId, peer);
void peer
.start()
.catch(() => false)
.then((started) => {
if (started || this.peers.get(childPeerId) !== peer) {
return;
}
this.disposePeer(childPeerId);
if (
attempt < 1 &&
!this.disposed &&
this.stream === stream &&
this.childPeerIds.includes(childPeerId)
) {
window.setTimeout(() => {
this.startPeer(childPeerId, stream, attempt + 1);
}, 500);
}
});
let peer: HostMediaPeer | undefined;
try {
peer = this.createPeer(childPeerId, stream);
this.peers.set(childPeerId, peer);
if (await peer.start()) return;
} catch (error) {
debugError("webrtc", "relay-start-failed", error, { connectionId: peer?.connectionId });
}
if (this.peers.get(childPeerId) !== peer) return;
if (peer) this.disposePeer(childPeerId);
if (
attempt < 1 &&
!this.disposed &&
this.stream === stream &&
this.childPeerIds.includes(childPeerId)
) {
window.setTimeout(() => {
void this.startPeer(childPeerId, stream, attempt + 1);
}, 500);
}
}
private createPeer(
+21
View File
@@ -0,0 +1,21 @@
import { afterEach, expect, it, vi } from "vitest";
import { BrowserEncodingOutput } from "../src/client/media/browser-encoding-output";
afterEach(() => vi.unstubAllGlobals());
it.each(["streams", "writer"])("releases its carrier when %s initialization fails", (stage) => {
const track = { stop: vi.fn(), contentHint: "" };
vi.stubGlobal("document", { createElement: () => ({
getContext: () => ({}), captureStream: () => ({ getVideoTracks: () => [track] }),
}) });
const failure = new DOMException("Already acquired", "InvalidStateError");
const sender = { createEncodedStreams: () => {
if (stage === "streams") throw failure;
return { writable: { getWriter: () => { throw failure; } } };
} } as unknown as RTCRtpSender;
const onFailure = vi.fn();
expect(() => new BrowserEncodingOutput(sender, onFailure, {})).toThrow(failure);
expect(track.stop).toHaveBeenCalledOnce();
// Construction has no live owner yet; start()'s caller handles its rejection.
expect(onFailure).not.toHaveBeenCalled();
});
+69
View File
@@ -99,6 +99,7 @@ class FakePeerConnection {
static activeCount = 0;
static peakActiveCount = 0;
static offersFailing = 0;
static constructorsFailing = 0;
static omitCodecPreferenceSetter = false;
static codecPreferenceCallsFailing = 0;
@@ -128,6 +129,10 @@ class FakePeerConnection {
>();
constructor(configuration?: RTCConfiguration) {
if (FakePeerConnection.constructorsFailing > 0) {
FakePeerConnection.constructorsFailing--;
throw new DOMException("Allocation failed", "UnknownError");
}
FakePeerConnection.latest = this;
FakePeerConnection.instances.push(this);
FakePeerConnection.activeCount += 1;
@@ -490,6 +495,7 @@ beforeEach(() => {
FakePeerConnection.activeCount = 0;
FakePeerConnection.peakActiveCount = 0;
FakePeerConnection.offersFailing = 0;
FakePeerConnection.constructorsFailing = 0;
FakePeerConnection.omitCodecPreferenceSetter = false;
FakePeerConnection.codecPreferenceCallsFailing = 0;
statsCallbacks.length = 0;
@@ -1844,6 +1850,26 @@ function hostProvisionalInput(
}
describe("Host provisional child runtime ownership", () => {
it("rejects failed construction, releases the clone and permits the next preparation", async () => {
const source = createTrack("video", "construction-source");
const sendSignal = vi.fn(() => true);
const owner = new HostProvisionalChild({ sendSignal });
const input = hostProvisionalInput(7, ["child"], createStream(source, null));
FakePeerConnection.constructorsFailing = 2;
expect(owner.prepare(input)).toBe(false);
expect(owner.prepare(input)).toBe(false);
expect(sendSignal).not.toHaveBeenCalled();
for (const result of vi.mocked(source.clone).mock.results) {
expect((result.value as MediaStreamTrack).stop).toHaveBeenCalledOnce();
}
expect(source.stop).not.toHaveBeenCalled();
expect(FakePeerConnection.activeCount).toBe(0);
expect(owner.prepare({ ...input, revision: 8 })).toBe(true);
await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce());
owner.discard();
expect(FakePeerConnection.activeCount).toBe(0);
});
it("reports connected preparation once, retries unsent progress and fences replacement", async () => {
const progress = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildConnected: progress });
@@ -2446,6 +2472,49 @@ describe("ViewerRelay downstream ownership", () => {
expect(progress).toHaveBeenCalledTimes(3);
});
it.each([false, true])("bounds ordinary relay construction retries; retired=%s", async (retired) => {
vi.useFakeTimers();
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true });
const source = createTrack("video", "relay-retry");
try {
relay.setStream(createStream(source, null));
await vi.advanceTimersByTimeAsync(0);
FakePeerConnection.constructorsFailing = 3;
relay.activateChildren(1, ["child"]);
expect(source.clone).toHaveBeenCalledTimes(1);
if (retired) relay.dispose();
await vi.advanceTimersByTimeAsync(2000);
expect(source.clone).toHaveBeenCalledTimes(retired ? 1 : 2);
for (const result of vi.mocked(source.clone).mock.results) {
expect((result.value as MediaStreamTrack).stop).toHaveBeenCalledOnce();
}
expect(source.stop).not.toHaveBeenCalled();
expect(FakePeerConnection.activeCount).toBe(0);
} finally {
relay.dispose();
vi.useRealTimers();
}
});
it("reports deferred prepared construction failure and accepts the next candidate", async () => {
const onPreparedChildFailed = vi.fn();
const sendSignal = vi.fn(() => true);
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"],
{ sendSignal, onPreparedChildFailed });
const source = createTrack("video", "relay-construction");
relay.setStream(createStream(source, null));
FakePeerConnection.constructorsFailing = 1;
expect(relay.prepareChild(1, routeCandidate(1, "child"), ["child"])).toBe(true);
await vi.waitFor(() => expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(1, "relay-candidate-1"));
expect(sendSignal).not.toHaveBeenCalled();
expect(vi.mocked(source.clone).mock.results[0]!.value.stop).toHaveBeenCalledOnce();
expect(source.stop).not.toHaveBeenCalled();
expect(relay.prepareChild(2, routeCandidate(2, "child"), ["child"])).toBe(true);
await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce());
relay.dispose();
expect(FakePeerConnection.activeCount).toBe(0);
});
it("reports a failed prepared transport once without blaming retained children", async () => {
const onPreparedChildFailed = vi.fn();
const relay = new ViewerRelay(
+48
View File
@@ -31,6 +31,7 @@ const ANSWER_SDP = [
].join("\r\n");
interface ConnectionPlan {
constructorError?: Error;
answerError?: Error;
candidateGates?: Promise<void>[];
localDescriptionGate?: Promise<void>;
@@ -100,6 +101,7 @@ class FakePeerConnection extends EventTarget {
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;
@@ -241,6 +243,52 @@ afterEach(() => {
});
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[] = [];