From 083c9273a7a0e5a4a675389d55d13d3c1f20d8ac Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:18:08 +0800 Subject: [PATCH] fix(routing): retain connected candidates during delayed viewer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Viewer progress and first-frame confirmations are delayed, an already connected P2P candidate can be discarded at the five-second scheduling boundary if another candidate is available. This can turn a working initial connection into another connection attempt. Host and relay parents now report transport progress through the existing room router. The router checks the exact participant/session, revision and connection; only the candidate Viewer can confirm first-frame readiness. The total operation deadline and the routing algorithm are unchanged. Browser and Native senders use their existing prepared-child owners, and discarded preparations cannot report progress. The loopback benchmark now identifies Peer and SFU connections from room signaling, excluding local codec probes and encoding-pool transports from media-edge counts. Its optional old Viewer-only quality canary remains unsuitable for the current sender-owned quality contract and is recorded in TODO. Validation: - `npm run check`: type checks, 1,000 tests and production Web build passed. - `npm run check:go-core`: formatting, Go tests, vet and supported Windows/Linux builds passed; Darwin capture remains a native-runner check. - Documentation and project-state checks passed. - Released v1.5.0 versus this candidate, delaying Viewer transport/ready feedback by six seconds: the old candidate connection closed after the scheduling boundary despite 79 decoded frames; the fixed connection stayed intact. Browser Host, Native Host and relay-parent paths were exercised, with another Viewer continuing to play. - Three-Viewer loopback topology and relay-departure recovery passed; Host and relay copy limits remained valid. - Actual App public-link signaling, with isolated 1/8/20-second packet loss on one Browser Viewer: playback recovered in all three cases and the control Viewer continued. Every test process, port and isolated profile was cleaned up. The original reporter's browser/network and paired diagnostics are unavailable. These bounded tests establish the repaired mechanism, not the cause of every brief connection or a representative NAT success rate. ## Release notes ### 中文 这是一次连接稳定性修复,改善进入房间时的 P2P 连接过程。 - 修复观众的连接确认反馈较慢时,已接通的线路可能被提前拆除,造成重复连接或延迟出画的问题。修复覆盖 App 分享、浏览器分享和观众中继。 - 使用 App 公网邀请或本地模式时,房主更新 App 后重新分享即可。使用已部署站点时,请管理员更新 Server,并刷新页面。 ### English This patch improves P2P connection stability when joining a room. - Fixed an established connection being discarded prematurely when Viewer confirmations arrive late, causing repeated connection attempts or delayed playback. The fix covers App sharing, browser sharing and Viewer relays. - For App public invitations or Local mode, the Host should update the App and start sharing again. For a deployed site, update the Server and refresh the page. --- docs/standards/routing-transport.md | 4 ++ docs/todo.md | 9 ++- internal/server/signal/router.go | 37 ++++++------ internal/server/signal/router_test.go | 68 ++++++++++++++++++++++ scripts/peer-assisted-benchmark.ts | 29 +++++---- src/client/media/host-provisional-child.ts | 14 +++++ src/client/pages/HostPage.tsx | 3 + src/client/pages/ViewerPage.tsx | 2 + src/client/webrtc/viewer-relay.ts | 14 +++++ tests/host-peer-tracks.test.ts | 52 +++++++++++++++++ 10 files changed, 202 insertions(+), 30 deletions(-) diff --git a/docs/standards/routing-transport.md b/docs/standards/routing-transport.md index 483de9f..f330a22 100644 --- a/docs/standards/routing-transport.md +++ b/docs/standards/routing-transport.md @@ -92,6 +92,10 @@ fallback remains. It advances a silent Peer candidate instead of delaying the next available route; it is not interpreted as terminal ICE failure. The final or only Peer candidate remains until the unchanged total operation deadline. Transport-connected progress also retains a candidate through that deadline. +Either exact preparing endpoint can report that progress, so a delayed Viewer +confirmation does not discard a transport already observed by its parent. +This cannot commit the route: first-frame readiness remains Viewer-owned, and +progress never extends the total operation deadline. Consequently a working SFU route can carry media while its one-candidate direct convergence uses the full background operation, and peer-only acquisition does not abandon its only possible route at the foreground boundary. diff --git a/docs/todo.md b/docs/todo.md index 4853e73..239c521 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -17,8 +17,10 @@ deployments remain separate. Physical limits are owned by - [ ] **Brief connection followed by repeated Viewer loss on v1.5.0.** One Viewer reportedly drops just after connection details appear, while other Viewers work; App public-link mode is suspected. Check signaling, first-frame - admission and current-edge recovery separately. The affected environment and - paired diagnostics are unavailable; no network or product cause is established. + admission and current-edge recovery separately. A delayed-confirmation + reproduction establishes one premature candidate replacement; the affected + environment and paired diagnostics are still unavailable, so the reporting + machine's cause remains unconfirmed. - [ ] **App source-discovery field acceptance.** Retest the missing-window report with the current App and page. Obtain OS/browser, version and paired Debug reports to distinguish discovery, capture capability and enumeration failures. @@ -67,6 +69,9 @@ not establish better connection success or speed; this note adds no retry policy when directed, preserving the owner's platform deferrals. Correlate freezes with publisher/receiver evidence before changing media policy. Run physical workloads serially from stable executable paths with cleanup between runs. + Before using the optional `viewer-mbb` benchmark canary, align its injected + evidence and capacity assertions with the current sender-owned quality + contract; its old Viewer-only trigger is not a valid quality acceptance gate. 2. **Broader quality work.** Reopen from measured benefit at acceptable complexity. Preserve chosen profiles, bitrate ceilings, endpoint capacity and P2P-first routing unless a new accepted decision supports changing them. No weighted diff --git a/internal/server/signal/router.go b/internal/server/signal/router.go index 26488b5..0391f9e 100644 --- a/internal/server/signal/router.go +++ b/internal/server/signal/router.go @@ -732,10 +732,21 @@ func (r *router) handleRouteReady(participant authenticatedRouteParticipant, mes r.requestPump(participant.roomID) } -func (r *router) handleRouteTransportConnected(participant authenticatedRouteParticipant, message protocol.RouteTransportConnectedMessage) { - if participant.role != protocol.RoleViewer { - return +// Either endpoint may report transport progress or local failure. First-frame +// readiness remains owned by the candidate Viewer in handleRouteReady. +func (r *router) ownsCandidateEndpoint(participant authenticatedRouteParticipant, operation *route.OperationSnapshot) bool { + if participant.role == protocol.RoleViewer && participant.peerID == operation.ChildPeerID && + participant.sessionID == operation.ChildSessionID { + return true } + if operation.Current.Tuple.Kind == route.UpstreamPeer && participant.peerID == operation.Current.Tuple.ParentPeerID { + peer, ok := r.connectedPeer(participant.roomID, participant.peerID) + return ok && peer.SessionID == participant.sessionID + } + return false +} + +func (r *router) handleRouteTransportConnected(participant authenticatedRouteParticipant, message protocol.RouteTransportConnectedMessage) { rm, _ := r.rooms.Get(participant.roomID) if rm == nil || rm.controller == nil { return @@ -744,17 +755,16 @@ func (r *router) handleRouteTransportConnected(participant authenticatedRoutePar operation := controller.Operation() revision := int64(message.Revision) if operation == nil || operation.Current == nil || - operation.ChildPeerID != participant.peerID || - operation.ChildSessionID != participant.sessionID || operation.Current.Revision != revision || operation.Current.ConnectionID != message.ConnectionID || - operation.Current.Tuple.Kind != route.UpstreamPeer { + operation.Current.Tuple.Kind != route.UpstreamPeer || + !r.ownsCandidateEndpoint(participant, operation) { return } before := controller.Revision() progressed := controller.CandidateTransportConnected(route.CandidateGuard{ - ChildPeerID: participant.peerID, - ChildSessionID: participant.sessionID, + ChildPeerID: operation.ChildPeerID, + ChildSessionID: operation.ChildSessionID, Revision: revision, ConnectionID: message.ConnectionID, }, r.now()) @@ -821,21 +831,14 @@ func (r *router) handleRouteFailed(participant authenticatedRouteParticipant, me return } current := operation.Current - ownsChild := participant.role == protocol.RoleViewer && - participant.peerID == operation.ChildPeerID && - participant.sessionID == operation.ChildSessionID - ownsParent := false - if current.Tuple.Kind == route.UpstreamPeer && participant.peerID == current.Tuple.ParentPeerID { - peer, ok := r.connectedPeer(participant.roomID, participant.peerID) - ownsParent = ok && peer.SessionID == participant.sessionID - } + ownsEndpoint := r.ownsCandidateEndpoint(participant, operation) ownsPublication := false if current.Tuple.Kind == route.UpstreamSfu && participant.role == protocol.RoleHost && participant.peerID == rm.hostPeerID { host, ok := r.store.GetConnectedHost(participant.roomID) ownsPublication = ok && host.SessionID == participant.sessionID } - if !ownsChild && !ownsParent && !ownsPublication { + if !ownsEndpoint && !ownsPublication { return } if connectionID != "" && connectionID != current.ConnectionID { diff --git a/internal/server/signal/router_test.go b/internal/server/signal/router_test.go index 54f99f9..ab87e44 100644 --- a/internal/server/signal/router_test.go +++ b/internal/server/signal/router_test.go @@ -21,6 +21,7 @@ package signal import ( "context" "errors" + "fmt" "io" "log/slog" "reflect" @@ -1515,6 +1516,73 @@ func TestRouterKeepsExactTransportConnectedDirectCandidatePastBoundary(t *testin }) } +func TestRouterAcceptsExactParentProgressWithoutGrantingReadiness(t *testing.T) { + for _, relayParent := range []bool{false, true} { + t.Run(fmt.Sprintf("relay=%v", relayParent), func(t *testing.T) { + h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, prepareTimeoutMs: 300, fakeTimers: true}) + created := h.createRoom() + host := h.connectHost(created, nil, "", "") + h.locked(func() { h.doComplete(host) }) + parents := map[string]authenticatedRouteParticipant{host.peerID: host} + if relayParent { + for _, name := range []string{"root-a", "root-b"} { + root := h.connectViewer(created, name) + h.locked(func() { h.doComplete(root) }) + prepared := h.waitPreparedTransport(root.sessionID, "direct") + h.routeReady(root, int64(prepared.Revision)) + h.locked(func() { h.doRelay(root, 2) }) + parents[root.peerID] = root + } + } + viewer := h.connectViewer(created, "delayed-feedback") + h.locked(func() { h.doComplete(viewer) }) + direct := h.waitPreparedTransport(viewer.sessionID, "direct") + h.locked(func() { + rm, _ := h.router.rooms.Get(created.RoomID) + before := rm.controller.Operation() + parent := parents[before.Current.Tuple.ParentPeerID] + if (parent.role == protocol.RoleViewer) != relayParent { + t.Fatal("unexpected preparing parent") + } + message := protocol.RouteTransportConnectedMessage{Type: "route-transport-connected", Revision: direct.Revision, ConnectionID: direct.Candidate.ConnectionID} + stale := parent + stale.sessionID = "retired-session" + h.router.handleRouteTransportConnected(stale, message) + foreign := parent + foreign.peerID = "unrelated-peer" + h.router.handleRouteTransportConnected(foreign, message) + wrong := message + wrong.ConnectionID = "retired-connection" + h.router.handleRouteTransportConnected(parent, wrong) + wrong = message + wrong.Revision++ + h.router.handleRouteTransportConnected(parent, wrong) + if rm.controller.Operation().WakeAtMs != before.WakeAtMs { + t.Fatal("unrelated progress changed candidate timing") + } + h.router.handleRouteTransportConnected(parent, message) + after := rm.controller.Operation() + if after == nil || after.DeadlineAtMs != before.DeadlineAtMs || after.WakeAtMs != before.DeadlineAtMs { + t.Fatal("parent progress did not retain the original total deadline") + } + h.doReady(parent, int64(direct.Revision)) + if rm.controller.Operation() == nil { + t.Fatal("parent committed a route without Viewer readiness") + } + }) + h.clock.advance(150) + if prepared, _ := h.preparedFor(viewer.sessionID); !reflect.DeepEqual(prepared, direct) { + t.Fatal("connected candidate was replaced before delayed Viewer readiness") + } + h.routeReady(viewer, int64(direct.Revision)) + h.waitFor("the active peer edge", func() bool { + active, ok := h.activeAfter(viewer.sessionID, int64(direct.Revision)-1) + return ok && active.Assignment.Upstream.Kind == "peer" + }) + }) + } +} + func TestRouterUsesDirectBoundaryToBootstrapSfuWhenEveryHostSlotIsFull(t *testing.T) { h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, prepareTimeoutMs: 300, fakeTimers: true}) created := h.createRoom() diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index f54d098..9e41942 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -153,7 +153,7 @@ interface FailurePageEvidence { connections: Array<{ connectionState: string; iceConnectionState: string; - identity: "peer" | "unidentified"; + identity: "peer" | "sfu" | "unidentified"; hasOutboundVideo: boolean; hasInboundVideo: boolean; decoded: boolean; @@ -788,9 +788,9 @@ function sanitizeFailurePageEvidence( ? connection.iceConnectionState : "unknown", identity: - connection.connectionId !== null || connection.remotePeerId !== null + connection.remotePeerId !== null ? "peer" - : "unidentified", + : connection.connectionId !== null ? "sfu" : "unidentified", hasOutboundVideo: connection.hasOutboundVideo, hasInboundVideo: connection.hasInboundVideo, decoded: (connection.receiveTotals?.framesTotal ?? 0) > 0, @@ -850,7 +850,7 @@ function inspectSfuPublication(pages: readonly PageObservation[]) { ? connection.sendTotals : connection.receiveTotals; return ( - connection.connectionId === null && + connection.connectionId !== null && connection.remotePeerId === null && connection.connectionState === "connected" && (direction === "send" @@ -1170,7 +1170,7 @@ function committedReceiveMetrics( connection.receive !== null && (upstream.kind === "peer" ? connection.remotePeerId === upstream.peerId - : connection.connectionId === null && connection.remotePeerId === null), + : connection.connectionId !== null && connection.remotePeerId === null), ); return matching.length === 1 ? matching[0]!.receive : null; } @@ -1825,7 +1825,7 @@ function buildBenchmarkInitScript(options: { } function recordDescription(message, direction) { - const payload = message && message.payload; + const payload = message.type === "sfu-signal" ? message : message.payload; if (!payload || payload.kind !== "description" || !payload.description) return; descriptions.push({ peerId: direction === "out" ? message.targetPeerId : message.fromPeerId, @@ -1833,6 +1833,7 @@ function buildBenchmarkInitScript(options: { sdpKey: sdpKey(payload.description.sdp), }); if (descriptions.length > 128) descriptions.shift(); + recordActiveOutboundEdges(); } function sanitizeRouteTimingSamples(value) { @@ -1912,7 +1913,7 @@ function buildBenchmarkInitScript(options: { ) { routeTimingSamples = sanitizeRouteTimingSamples(message.snapshot); } - if (message.type === "signal") recordDescription(message, direction); + if (message.type === "signal" || message.type === "sfu-signal") recordDescription(message, direction); } const NativeWebSocket = globalThis.WebSocket; @@ -1974,7 +1975,7 @@ function buildBenchmarkInitScript(options: { const NativePeerConnection = globalThis.RTCPeerConnection; function recordActiveOutboundEdges() { - const active = connections.filter(({ connection }) => + const active = networkConnections().filter(({ connection }) => connection.connectionState !== "closed" && connection.connectionState !== "failed" && connection.getSenders().some((sender) => sender.track && sender.track.kind === "video") @@ -2084,6 +2085,12 @@ function buildBenchmarkInitScript(options: { return { connectionId: null, remotePeerId: null }; } + // Local codec probes and encoding-pool transports never cross room signaling. + // Count actual Peer/SFU media edges, not every RTCPeerConnection in the page. + function networkConnections() { + return connections.filter(({ connection }) => identify(connection).connectionId !== null); + } + function totals(report, type) { let selected = null; report.forEach((raw) => { @@ -2162,13 +2169,13 @@ function buildBenchmarkInitScript(options: { } async function sample() { - const sampledConnections = await Promise.all(connections.map(connectionSample)); + const sampledConnections = await Promise.all(networkConnections().map(connectionSample)); return { ...baseSnapshot(), connections: sampledConnections }; } async function progress() { const values = []; - for (const record of connections) { + for (const record of networkConnections()) { const connection = record.connection; const hasOutboundVideo = connection.getSenders().some((sender) => sender.track && sender.track.kind === "video"); const hasInboundVideo = connection.getReceivers().some((receiver) => receiver.track && receiver.track.kind === "video"); @@ -2195,7 +2202,7 @@ function buildBenchmarkInitScript(options: { function snapshot() { return { ...baseSnapshot(), - connections: connections.map((record) => ({ + connections: networkConnections().map((record) => ({ index: record.index, ...identify(record.connection), connectionState: record.connection.connectionState, diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index 1c440e7..b139212 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -47,6 +47,7 @@ interface HostProvisionalChildEvents { ) => void; onPromotedUpdate?: (peer: HostMediaPeer, snapshot: PeerSnapshot) => void; onPreparedChildFailed?: (revision: number, connectionId: string) => void; + onPreparedChildConnected?: (revision: number, connectionId: string) => boolean; } export type HostPreparedChildActivation = @@ -58,6 +59,7 @@ interface PreparedHostChild { candidate: PreparedRouteCandidate; peer: HostMediaPeer; failed: boolean; + connectedSent: boolean; replacesConnectionId: string | null; } @@ -119,6 +121,17 @@ export class HostProvisionalChild { : false, onUpdate: (snapshot: PeerSnapshot) => { if (this.prepared?.peer === peer) { + const prepared = this.prepared; + if ( + !prepared.failed && !prepared.connectedSent && + snapshot.connectionState === "connected" && + snapshot.connectionId === candidate.connectionId && + snapshot.peerId === candidate.childPeerId + ) { + prepared.connectedSent = this.events.onPreparedChildConnected?.( + revision, candidate.connectionId, + ) ?? false; + } if (snapshot.connectionState === "failed") { this.fail(peer); } @@ -150,6 +163,7 @@ export class HostProvisionalChild { candidate: { ...candidate }, peer, failed: false, + connectedSent: false, replacesConnectionId: this.events.activeConnectionId?.(candidate.childPeerId) ?? null, }; diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 70912b5..ae4140b 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -1901,6 +1901,9 @@ export function HostPage({ onPreparedChildFailed: (revision, connectionId) => { reportPreparedHostChildFailure(revision, connectionId, generation); }, + onPreparedChildConnected: (revision, connectionId) => + isCurrentGeneration(generation) && signalRef.current === signal && + signal.send({ type: "route-transport-connected", revision, connectionId }), createPeer: nativeClient && nativeShareGeneration ? (candidate, input, events) => { diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index d6de97c..1ff7617 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -843,6 +843,8 @@ export function ViewerPage({ } }, onPreparedChildFailed: reportPreparedChildFailure, + onPreparedChildConnected: (revision, connectionId) => + active && signal.send({ type: "route-transport-connected", revision, connectionId }), }, endpointMediaCopyCapacity, currentRoutePolicy.natPrediction, diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index 0120910..1ff1356 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -25,6 +25,7 @@ export interface ViewerRelayEvents { onUpdate?: (snapshot: PeerSnapshot | null) => void; onSenderUpdate?: (snapshot: PeerSnapshot, revision: number | null) => void; onPreparedChildFailed?: (revision: number, connectionId: string) => void; + onPreparedChildConnected?: (revision: number, connectionId: string) => boolean; } interface PreparedChild { revision: number; @@ -32,6 +33,7 @@ interface PreparedChild { candidate: PreparedRouteCandidate; peer: HostMediaPeer; failed: boolean; + connectedSent: boolean; replacesConnectionId: string | null; } @@ -376,6 +378,7 @@ export class ViewerRelay { candidate: { ...candidate }, peer, failed: false, + connectedSent: false, replacesConnectionId: this.peers.get(candidate.childPeerId)?.connectionId ?? null, }; @@ -539,6 +542,17 @@ export class ViewerRelay { : false, onUpdate: (snapshot) => { if (this.preparedChild?.peer === peer) { + const prepared = this.preparedChild; + if ( + !prepared.failed && !prepared.connectedSent && + snapshot.connectionState === "connected" && + snapshot.connectionId === prepared.candidate.connectionId && + snapshot.peerId === prepared.childPeerId + ) { + prepared.connectedSent = this.events.onPreparedChildConnected?.( + prepared.revision, snapshot.connectionId, + ) ?? false; + } if (snapshot.connectionState === "failed") { this.failPreparedChild(peer); } diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 3653fd4..5295fd5 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -1763,6 +1763,30 @@ function hostProvisionalInput( } describe("Host provisional child runtime ownership", () => { + 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 }); + const stream = createStream(createTrack("video", "progress-video"), null); + owner.prepare(hostProvisionalInput(7, ["child"], stream)); + const first = FakePeerConnection.latest!; + first.connectionState = "connected"; + first.dispatchEvent(new Event("connectionstatechange")); + first.dispatchEvent(new Event("connectionstatechange")); + first.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(2); + expect(progress).toHaveBeenLastCalledWith(7, "candidate-connection-7"); + owner.prepare(hostProvisionalInput(8, ["replacement"], stream)); + first.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(2); + const second = FakePeerConnection.latest!; + second.connectionState = "connected"; + second.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenLastCalledWith(8, "candidate-connection-8"); + owner.discard(); + second.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(3); + }); + it("accepts only the exact connection and promotes the same peer", async () => { const signals: Array<{ peerId: string; connectionId: string }> = []; const promotedUpdates = vi.fn(); @@ -2313,6 +2337,34 @@ describe("ViewerRelay downstream ownership", () => { expect(onPreparedChildFailed).toHaveBeenCalledTimes(1); }); + it("reports relay transport progress once and retires its reporting scope", async () => { + const progress = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const relay = new ViewerRelay( + { iceServers: [] }, QUALITY_PROFILES["720p30"], + { sendSignal: () => true, onPreparedChildConnected: progress }, + ); + relay.setStream(createStream(createTrack("video", "relay-progress"), null)); + relay.prepareChild(7, routeCandidate(7, "child"), ["child"]); + await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull()); + const first = FakePeerConnection.latest!; + first.connectionState = "connected"; + first.dispatchEvent(new Event("connectionstatechange")); + first.dispatchEvent(new Event("connectionstatechange")); + first.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(2); + expect(progress).toHaveBeenLastCalledWith(7, "relay-candidate-7"); + relay.prepareChild(8, routeCandidate(8, "replacement"), ["replacement"]); + first.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(2); + const second = FakePeerConnection.latest!; + second.connectionState = "connected"; + second.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenLastCalledWith(8, "relay-candidate-8"); + relay.dispose(); + second.dispatchEvent(new Event("connectionstatechange")); + expect(progress).toHaveBeenCalledTimes(3); + }); + it("reports a failed prepared transport once without blaming retained children", async () => { const onPreparedChildFailed = vi.fn(); const relay = new ViewerRelay(