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:
14 files changed
+269
-90
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, [&] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */ }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in new issue
Block a user