From 04cbb2c9f45a6a49c1b14006dfc62ddfb5765b66 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:08:42 +0800 Subject: [PATCH] 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. --- docs/standards/engineering.md | 3 + docs/standards/media-quality.md | 11 +-- docs/todo.md | 32 +++++---- native/capture/windows/h264_encoder.cpp | 2 +- native/capture/windows/main.cpp | 9 ++- native/capture/windows/output_worker.test.cpp | 19 ++--- src/client/media/browser-encoding-output.ts | 10 ++- src/client/media/host-provisional-child.ts | 11 ++- src/client/webrtc/host-peer.ts | 21 +++--- src/client/webrtc/viewer-peer.ts | 36 ++++++---- src/client/webrtc/viewer-relay.ts | 67 ++++++++++-------- tests/browser-encoding-output.test.ts | 21 ++++++ tests/host-peer-tracks.test.ts | 69 +++++++++++++++++++ tests/viewer-peer-generation.test.ts | 48 +++++++++++++ 14 files changed, 269 insertions(+), 90 deletions(-) create mode 100644 tests/browser-encoding-output.test.ts diff --git a/docs/standards/engineering.md b/docs/standards/engineering.md index 617d82c..c6b0619 100644 --- a/docs/standards/engineering.md +++ b/docs/standards/engineering.md @@ -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. diff --git a/docs/standards/media-quality.md b/docs/standards/media-quality.md index f610714..863dc28 100644 --- a/docs/standards/media-quality.md +++ b/docs/standards/media-quality.md @@ -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. diff --git a/docs/todo.md b/docs/todo.md index 232ccf1..705deaa 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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. diff --git a/native/capture/windows/h264_encoder.cpp b/native/capture/windows/h264_encoder.cpp index 2b3dd46..c58dd9a 100644 --- a/native/capture/windows/h264_encoder.cpp +++ b/native/capture/windows/h264_encoder.cpp @@ -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) { diff --git a/native/capture/windows/main.cpp b/native/capture/windows/main.cpp index 7d4db14..c50e0d1 100644 --- a/native/capture/windows/main.cpp +++ b/native/capture/windows/main.cpp @@ -1268,13 +1268,13 @@ struct VideoEncoderSelection final { template VideoEncoderSelection CompareSoftwareEncoder(std::unique_ptr hardware, std::optional 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, [&] { diff --git a/native/capture/windows/output_worker.test.cpp b/native/capture/windows/output_worker.test.cpp index 38d0950..245d4e2 100644 --- a/native/capture/windows/output_worker.test.cpp +++ b/native/capture/windows/output_worker.test.cpp @@ -171,19 +171,14 @@ void CheckAutoEncoderFallback() { assert((selected.kind == OutputKind::vp8) == (software_work <= .04)); assert(static_cast(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); } } diff --git a/src/client/media/browser-encoding-output.ts b/src/client/media/browser-encoding-output.ts index 395f36a..de03a0d 100644 --- a/src/client/media/browser-encoding-output.ts +++ b/src/client/media/browser-encoding-output.ts @@ -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>; + 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. */ } diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index b139212..a5283b9 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -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; } diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 9e40297..7a8564a 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -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, diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index 036cef1..45fcdd7 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -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(); } } diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index 1ff1356..ff8fa84 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -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 { 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( diff --git a/tests/browser-encoding-output.test.ts b/tests/browser-encoding-output.test.ts new file mode 100644 index 0000000..85bc8e0 --- /dev/null +++ b/tests/browser-encoding-output.test.ts @@ -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(); +}); diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 32a279c..5564795 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -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( diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index 7755179..f34236b 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -31,6 +31,7 @@ const ANSWER_SDP = [ ].join("\r\n"); interface ConnectionPlan { + constructorError?: Error; answerError?: Error; candidateGates?: Promise[]; localDescriptionGate?: Promise; @@ -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[] = [];