From cd534c7e68868968cccc87355852c252239efcc0 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:15:39 +0800 Subject: [PATCH] refactor(client): simplify route candidate proof --- src/client/media/host-provisional-child.ts | 241 +- src/client/media/host-sfu-route.ts | 226 +- src/client/media/route-transition.ts | 12 + src/client/media/viewer-sfu-route.ts | 226 +- src/client/pages/HostPage.tsx | 154 +- src/client/pages/ViewerPage.tsx | 377 ++- src/client/webrtc/viewer-relay.ts | 281 +-- tests/host-peer-tracks.test.ts | 660 +----- tests/route-transition.test.ts | 2394 ++------------------ 9 files changed, 946 insertions(+), 3625 deletions(-) diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index fb15712..57d36af 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -1,6 +1,7 @@ import type { IceConfig, ParticipantRouteAssignment, + ServerMessage, SignalPayload, } from "../../shared/protocol"; import { countEndpointMediaCopies } from "../../shared/media-copy-accounting"; @@ -8,68 +9,15 @@ import { HostPeer } from "../webrtc/host-peer"; import type { PeerSnapshot } from "../types"; import type { QualityProfile } from "./quality"; -export interface HostPreparedChildIdentity { - revision: number; - peerId: string; - failedConnectionId: string | null; -} - -export interface ActiveSelectedHostChild { - peerId: string; - connectionId: string; - currentRouteRevision: number; - pendingCarryRevision: number | null; -} - -export function hostSelectedChildForConnection( - children: ReadonlyMap, - peerId: string, - connectionId: string, -): ActiveSelectedHostChild | undefined { - const child = children.get(peerId); - return child?.connectionId === connectionId ? child : undefined; -} - -export function carryHostSelectedChild( - children: Map, - peerId: string, - connectionId: string, - revision: number, -): boolean { - const child = hostSelectedChildForConnection( - children, - peerId, - connectionId, - ); - if (!child || revision < child.currentRouteRevision) { - return false; - } - child.currentRouteRevision = revision; - child.pendingCarryRevision = revision; - return true; -} - -export function acceptHostSelectedChildrenRevision( - children: Map, - revision: number, -): string[] { - const removedPeerIds: string[] = []; - for (const [peerId, child] of children) { - if (child.pendingCarryRevision === revision) { - child.pendingCarryRevision = null; - } else { - children.delete(peerId); - removedPeerIds.push(peerId); - } - } - return removedPeerIds; -} +type SelectedEdgeTurn = Extract< + ServerMessage, + { type: "selected-edge-turn"; edgeKind: "peer-selected" } +>; interface HostProvisionalInput { revision: number; assignment: ParticipantRouteAssignment; activeChildPeerIds: readonly string[]; - selectedPeerIds: Iterable; maxMediaEdges: number; } @@ -81,53 +29,104 @@ interface HostProvisionalPrepareInput extends HostProvisionalInput { interface HostProvisionalChildEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; - hasActivePeer?: (peerId: string) => boolean; + activeConnectionId?: (peerId: string) => string | null; onPromotedStreamFailure?: (peer: HostPeer) => void; onPromotedUpdate?: (peer: HostPeer, snapshot: PeerSnapshot) => void; } -export type HostPreparedChildResolution = - | { kind: "ordinary" } - | { kind: "promote"; peerId: string } - | { kind: "failed"; peerId: string; connectionId: string }; - export type HostPreparedChildActivation = - | Exclude + | { kind: "ordinary" } | { kind: "promote"; peerId: string; peer: HostPeer }; -interface PreparedHostChild extends HostPreparedChildIdentity { +interface PreparedHostChild { + revision: number; + peerId: string; peer: HostPeer; + failed: boolean; + replacesConnectionId: string | null; } export class HostProvisionalChild { private prepared: PreparedHostChild | null = null; + private preparedRevision: number | null = null; + private plannedChildPeerIds: string[] = []; private promotedPeer: HostPeer | null = null; private signalingPeer: HostPeer | null = null; constructor(private readonly events: HostProvisionalChildEvents) {} prepare(input: HostProvisionalPrepareInput): boolean { - const peerId = plannedHostProvisionalChild(input); - if ( - this.promotedPeer || - !peerId || - this.events.hasActivePeer?.(peerId) - ) { + const planned = validPlannedHostChildren(input); + if (this.promotedPeer || !planned) { this.discard(); return false; } if ( - this.prepared?.revision === input.revision && - this.prepared.peerId === peerId + this.preparedRevision === input.revision && + samePeerIds(this.plannedChildPeerIds, planned) ) { - return this.prepared.failedConnectionId === null; + return this.prepared ? !this.prepared.failed : true; } + const peerId = planned.find( + (candidate) => !input.activeChildPeerIds.includes(candidate), + ); this.discard(); + this.preparedRevision = input.revision; + this.plannedChildPeerIds = planned; + if (!peerId) { + return true; + } + if (this.events.activeConnectionId?.(peerId)) { + this.discard(); + return false; + } + this.startPrepared(input.revision, peerId, input); + return true; + } + + prepareSelectedTurn( + message: SelectedEdgeTurn, + input: Pick, + now = Date.now(), + ): boolean { + if ( + this.preparedRevision !== message.revision || + !this.plannedChildPeerIds.includes(message.viewerPeerId) || + Date.parse(message.expiresAt) <= now + ) { + return false; + } + const prepared = this.prepared; + if (prepared && prepared.peerId !== message.viewerPeerId) { + return false; + } + if (prepared?.peer.connectionId === message.newConnectionId) { + return !prepared.failed; + } + const oldConnectionId = + prepared?.peer.connectionId ?? + this.events.activeConnectionId?.(message.viewerPeerId); + if (oldConnectionId !== message.oldConnectionId) { + return false; + } + this.discardPeer(); + this.startPrepared(message.revision, message.viewerPeerId, input, message); + return true; + } + + private startPrepared( + revision: number, + peerId: string, + input: Pick, + selectedTurn?: SelectedEdgeTurn, + ): void { let peer: HostPeer; peer = new HostPeer( peerId, - input.iceConfig, + selectedTurn + ? { iceServers: [selectedTurn.iceServer] } + : input.iceConfig, input.stream, input.profile, { @@ -147,13 +146,16 @@ export class HostProvisionalChild { } }, }, + selectedTurn !== undefined, + selectedTurn?.newConnectionId, ); this.signalingPeer = peer; this.prepared = { - revision: input.revision, + revision, peerId, peer, - failedConnectionId: null, + failed: false, + replacesConnectionId: selectedTurn?.oldConnectionId ?? null, }; void peer .start() @@ -163,14 +165,13 @@ export class HostProvisionalChild { this.fail(peer); } }); - return true; } ownsSignal(fromPeerId: string, payload: SignalPayload): boolean { const prepared = this.prepared; return Boolean( prepared && - prepared.failedConnectionId === null && + !prepared.failed && prepared.peerId === fromPeerId && prepared.peer.connectionId === payload.connectionId, ); @@ -210,29 +211,35 @@ export class HostProvisionalChild { activate(input: HostProvisionalInput): HostPreparedChildActivation { const prepared = this.prepared; - const activation = resolveHostPreparedChildActivation({ - ...input, - prepared, - }); - if (activation.kind === "promote") { - if (prepared) { - this.prepared = null; - this.promotedPeer = prepared.peer; - return { ...activation, peer: prepared.peer }; - } - this.discard(); - return { kind: "ordinary" }; - } - if (activation.kind === "failed") { + const currentConnectionId = prepared + ? this.events.activeConnectionId?.(prepared.peerId) ?? null + : null; + if ( + prepared && + !prepared.failed && + this.preparedRevision === input.revision && + prepared.revision === input.revision && + input.assignment.childPeerIds.includes(prepared.peerId) && + (!input.activeChildPeerIds.includes(prepared.peerId) || + prepared.replacesConnectionId === currentConnectionId) + ) { this.prepared = null; - prepared?.peer.dispose(); - return activation; + this.preparedRevision = null; + this.plannedChildPeerIds = []; + this.promotedPeer = prepared.peer; + return { kind: "promote", peerId: prepared.peerId, peer: prepared.peer }; } this.discard(); - return activation; + return { kind: "ordinary" }; } discard(): void { + this.preparedRevision = null; + this.plannedChildPeerIds = []; + this.discardPeer(); + } + + private discardPeer(): void { const prepared = this.prepared; this.prepared = null; if (this.signalingPeer === prepared?.peer) { @@ -242,7 +249,7 @@ export class HostProvisionalChild { } private livePeer(): HostPeer | null { - return this.prepared?.failedConnectionId === null + return this.prepared && !this.prepared.failed ? this.prepared.peer : null; } @@ -251,7 +258,7 @@ export class HostProvisionalChild { if (this.prepared?.peer !== peer) { return; } - this.prepared.failedConnectionId = peer.connectionId; + this.prepared.failed = true; if (this.signalingPeer === peer) { this.signalingPeer = null; } @@ -259,48 +266,30 @@ export class HostProvisionalChild { } } -export function plannedHostProvisionalChild( +function validPlannedHostChildren( input: HostProvisionalInput, -): string | null { +): string[] | null { const childPeerIds = [...new Set(input.assignment.childPeerIds)]; - const peerId = childPeerIds.find( - (candidate) => !input.activeChildPeerIds.includes(candidate), - ); if ( childPeerIds.length !== input.assignment.childPeerIds.length || - childPeerIds.length !== input.activeChildPeerIds.length + 1 || + childPeerIds.length < input.activeChildPeerIds.length || + childPeerIds.length > input.activeChildPeerIds.length + 1 || input.activeChildPeerIds.some( (candidate) => !childPeerIds.includes(candidate), ) || countEndpointMediaCopies({ childPeerIds: input.assignment.childPeerIds, publicationGeneration: input.assignment.sfuPublicationGeneration, - selectedChildPeerIds: input.selectedPeerIds, }) > input.maxMediaEdges ) { return null; } - return peerId ?? null; + return childPeerIds; } -export function resolveHostPreparedChildActivation( - input: HostProvisionalInput & { - prepared: HostPreparedChildIdentity | null; - }, -): HostPreparedChildResolution { - const peerId = plannedHostProvisionalChild(input); - if ( - !peerId || - input.prepared?.revision !== input.revision || - input.prepared.peerId !== peerId - ) { - return { kind: "ordinary" }; - } - return input.prepared.failedConnectionId - ? { - kind: "failed", - peerId, - connectionId: input.prepared.failedConnectionId, - } - : { kind: "promote", peerId }; +function samePeerIds(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((peerId, index) => peerId === right[index]) + ); } diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts index 2eb2adc..776a228 100644 --- a/src/client/media/host-sfu-route.ts +++ b/src/client/media/host-sfu-route.ts @@ -40,7 +40,6 @@ interface HostPublisherSlot { connected: boolean; active: boolean; failed: boolean; - acknowledgeActive: boolean; connectionId: string; selectedEdgeTurn: boolean; } @@ -71,6 +70,7 @@ export class HostSfuRoute { private transitionTail: Promise = Promise.resolve(); private resyncGeneration = 0; private resyncing = false; + private paused = false; private closed = false; private lastConfig: Extract | null = null; @@ -81,7 +81,7 @@ export class HostSfuRoute { constructor(private readonly events: HostSfuRouteEvents) {} - accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult { + accept(update: RouteUpdateInput): RouteUpdateResult { if (this.closed) { return "stale"; } @@ -90,6 +90,10 @@ export class HostSfuRoute { if (result === "stale") { return result; } + if (this.paused) { + this.clearPending(); + return result; + } if (previousRevision !== update.revision) { this.recovery = null; this.lastFailureStage = null; @@ -116,16 +120,15 @@ export class HostSfuRoute { const token = this.route.token(); if (token && !this.resyncing) { - void this.queueActivation(token, acknowledge); + void this.queueActivation(token); } return result; } async acceptAndWait( update: RouteUpdateInput, - acknowledge = true, ): Promise { - const result = this.accept(update, acknowledge); + const result = this.accept(update); await this.transitionTail; return result; } @@ -134,7 +137,7 @@ export class HostSfuRoute { update: RouteUpdateInput, ): Promise { this.lastFailureStage = null; - const result = this.accept(update, false); + const result = this.accept(update); if (result !== "stale" || this.closed) { await this.transitionTail; return result; @@ -176,7 +179,7 @@ export class HostSfuRoute { this.resyncing = false; const token = this.route.token(); if (token && this.route.getPhase() === "active") { - await this.queueActivation(token, false); + await this.queueActivation(token); } return accepted; } @@ -184,7 +187,7 @@ export class HostSfuRoute { async acceptConfig( message: Extract, ): Promise { - if (this.closed || !this.route.acceptsConfig(message.revision)) { + if (this.closed || this.paused || !this.route.acceptsConfig(message.revision)) { return; } const token = this.route.token(); @@ -198,10 +201,8 @@ export class HostSfuRoute { if (this.active?.publicationGeneration === publicationGeneration) { this.active.revision = message.revision; - if (phase === "prepare" && this.active.connected) { - this.ready(message.revision, "prepare"); - } else if (phase === "active" && this.active.active) { - await this.queueActivation(token, true); + if (phase === "active" && this.active.active) { + await this.queueActivation(token); } return; } @@ -210,9 +211,6 @@ export class HostSfuRoute { this.pending.publicationGeneration === publicationGeneration && !this.pending.failed ) { - if (this.pending.connected && phase === "prepare") { - this.ready(message.revision, "prepare"); - } return; } @@ -241,7 +239,6 @@ export class HostSfuRoute { connected: false, active: false, failed: false, - acknowledgeActive: false, connectionId: selectedEdgeTurn?.newConnectionId ?? publicationGeneration, selectedEdgeTurn: selectedEdgeTurn !== null, }; @@ -271,10 +268,11 @@ export class HostSfuRoute { return; } slot.connected = true; - if (phase === "prepare") { - this.ready(message.revision, "prepare"); - } else if (!this.resyncing) { - await this.queueActivation(token, true); + if (!(await this.preparePublisher(slot))) { + return; + } + if (phase === "active" && !this.resyncing) { + await this.queueActivation(token); } } catch { await disconnectPublisher(publisher); @@ -282,6 +280,14 @@ export class HostSfuRoute { } } + setPaused(paused: boolean): void { + this.paused = paused; + if (paused) { + this.clearPending(); + this.selectedEdgeTurn = null; + } + } + /** Apply one controller-selected TURN grant to the next SFU publisher PC. */ startSelectedEdgeTurn( message: Extract< @@ -292,20 +298,33 @@ export class HostSfuRoute { if ( this.closed || !this.route.acceptsConfig(message.revision) || + this.route.getPhase() !== "prepare" || Date.parse(message.expiresAt) <= Date.now() ) { return false; } const assignment = this.route.getPlannedAssignment(); const publicationGeneration = assignment?.sfuPublicationGeneration; + const pending = this.pending; if ( !publicationGeneration || publicationGeneration !== message.publicationGeneration || - message.oldConnectionId !== publicationGeneration || - this.active?.publicationGeneration === publicationGeneration + (pending !== null && + (pending.revision !== message.revision || + pending.publicationGeneration !== publicationGeneration)) ) { return false; } + if (pending?.connectionId === message.newConnectionId) { + return !pending.failed; + } + if ( + (pending?.connectionId ?? publicationGeneration) !== + message.oldConnectionId + ) { + return false; + } + this.clearPending(); this.selectedEdgeTurn = message; if (this.lastConfig?.revision === message.revision) { void this.acceptConfig(this.lastConfig); @@ -314,9 +333,14 @@ export class HostSfuRoute { } updateProfile(profile: QualityProfile): Promise { - return this.active?.active - ? this.active.publisher.updateProfile(profile).catch(() => false) - : Promise.resolve(true); + const slots = this.publishingSlots(); + return slots.length === 0 + ? Promise.resolve(true) + : Promise.all( + slots.map((slot) => + slot.publisher.updateProfile(profile).catch(() => false), + ), + ).then((results) => results.every(Boolean)); } getQualityWarning(): string | null { @@ -335,9 +359,21 @@ export class HostSfuRoute { } replaceStream(stream: MediaStream): Promise { - return this.active - ? this.active.publisher.replaceStream(stream).catch(() => false) - : Promise.resolve(true); + const slots = this.publishingSlots(); + return slots.length === 0 + ? Promise.resolve(true) + : Promise.all( + slots.map((slot) => + slot.publisher.replaceStream(stream).catch(() => false), + ), + ).then((results) => results.every(Boolean)); + } + + private publishingSlots(): HostPublisherSlot[] { + return [...new Set([this.active, this.pending])].filter( + (slot): slot is HostPublisherSlot => + slot !== null && slot.active && !slot.failed, + ); } async failActivePublisher(): Promise { @@ -382,17 +418,14 @@ export class HostSfuRoute { }); } - private queueActivation( - token: RouteOperationToken, - acknowledge: boolean, - ): Promise { + private queueActivation(token: RouteOperationToken): Promise { return this.queueTransition(async () => { if (this.closed || this.resyncing || !this.route.owns(token, "active")) { return; } const assignment = this.route.getActiveAssignment(); if (assignment) { - await this.activate(assignment, token, acknowledge); + await this.activate(assignment, token); } }); } @@ -406,7 +439,6 @@ export class HostSfuRoute { private async activate( assignment: ParticipantRouteAssignment, token: RouteOperationToken, - acknowledge: boolean, ): Promise { const publicationGeneration = assignment.sfuPublicationGeneration; if (publicationGeneration === null) { @@ -416,7 +448,7 @@ export class HostSfuRoute { return; } this.events.reconcileChildren(assignment.childPeerIds); - this.commitMedia(token, acknowledge); + this.commitMedia(token); return; } @@ -429,9 +461,7 @@ export class HostSfuRoute { this.active.revision = token.revision; this.clearPending(); if (this.active.active) { - this.commitMedia(token, acknowledge); - } else { - this.active.acknowledgeActive ||= acknowledge; + this.commitMedia(token); } return; } @@ -441,89 +471,84 @@ export class HostSfuRoute { !pending || pending.revision !== token.revision || pending.publicationGeneration !== publicationGeneration || - !pending.connected + !pending.connected || + !pending.active ) { - await this.retireActive(); - if (acknowledge && this.route.owns(token, "active")) { + if (this.route.owns(token, "active")) { this.requestRecovery(token.revision); } return; } - await this.retireActive(pending); - if (!this.route.owns(token, "active")) { - this.clearPending(pending); - return; - } this.pending = null; + const previous = this.active; this.active = pending; - pending.acknowledgeActive ||= acknowledge; - const publishedStream = this.events.getStream(); - const publishedProfile = this.events.getProfile(); - if (!publishedStream) { + if (!this.route.markMediaActive(token)) { + this.active = previous; await disconnectPublisher(pending.publisher); - this.handleFailure(pending); return; } - - try { - const activated = await pending.publisher.activate( - publishedStream, - publishedProfile, - ); - const activeToken = this.currentActiveToken(pending); - if (!activated || !activeToken) { - if (this.active === pending) { - this.active = null; - } - await disconnectPublisher(pending.publisher); - if (!activated && activeToken) { - this.handleFailure(pending); - } - return; + this.lastFailureStage = null; + this.recovery = null; + if (previous && previous !== pending) { + if (previous.active) { + await previous.publisher.deactivate().catch(() => false); } - pending.active = true; + await disconnectPublisher(previous.publisher); + } + } + private async preparePublisher(slot: HostPublisherSlot): Promise { + const stream = this.events.getStream(); + const profile = this.events.getProfile(); + if (!stream) { + this.handleFailure(slot); + return false; + } + try { + if (!(await slot.publisher.activate(stream, profile))) { + this.handleFailure(slot); + await disconnectPublisher(slot.publisher); + return false; + } + if (this.pending !== slot || !this.ownsPublisherSlot(slot)) { + await disconnectPublisher(slot.publisher); + return false; + } + slot.active = true; const latestStream = this.events.getStream(); if ( latestStream && - latestStream !== publishedStream && - !(await pending.publisher.replaceStream(latestStream)) + latestStream !== stream && + !(await slot.publisher.replaceStream(latestStream)) ) { - await disconnectPublisher(pending.publisher); - this.handleFailure(pending); - return; + this.handleFailure(slot); + await disconnectPublisher(slot.publisher); + return false; } const latestProfile = this.events.getProfile(); if ( - latestProfile !== publishedProfile && - !(await pending.publisher.updateProfile(latestProfile)) + latestProfile !== profile && + !(await slot.publisher.updateProfile(latestProfile)) ) { - await disconnectPublisher(pending.publisher); - this.handleFailure(pending); - return; + this.handleFailure(slot); + await disconnectPublisher(slot.publisher); + return false; } + return this.pending === slot && this.ownsPublisherSlot(slot); } catch { - await disconnectPublisher(pending.publisher); - this.handleFailure(pending); - return; + this.handleFailure(slot); + await disconnectPublisher(slot.publisher); + return false; } + } - const activeToken = this.currentActiveToken(pending); - if (!activeToken) { - if (this.active === pending) { - this.active = null; - } - await disconnectPublisher(pending.publisher); - return; - } - if (!this.route.owns(token, "active")) { - pending.acknowledgeActive = false; - return; - } - const acknowledgeActive = pending.acknowledgeActive; - pending.acknowledgeActive = false; - this.commitMedia(activeToken, acknowledgeActive); + private ownsPublisherSlot(slot: HostPublisherSlot): boolean { + return ( + this.route.getRevision() === slot.revision && + this.route.getPlannedAssignment()?.sfuPublicationGeneration === + slot.publicationGeneration + ); } private currentActiveToken( @@ -542,15 +567,12 @@ export class HostSfuRoute { return token; } - private commitMedia(token: RouteOperationToken, acknowledge: boolean): void { + private commitMedia(token: RouteOperationToken): void { if (!this.route.markMediaActive(token)) { return; } this.lastFailureStage = null; this.recovery = null; - if (acknowledge) { - this.ready(token.revision, "active"); - } } private clearPending(expected?: HostPublisherSlot): void { @@ -648,10 +670,6 @@ export class HostSfuRoute { }); } - private ready(revision: number, phase: MediaRoutePhase): void { - this.events.send({ type: "route-ready", revision, phase }); - } - private handlePublisherStats( slot: HostPublisherSlot, metrics: ConnectionMetrics | null, diff --git a/src/client/media/route-transition.ts b/src/client/media/route-transition.ts index fb47868..17497e1 100644 --- a/src/client/media/route-transition.ts +++ b/src/client/media/route-transition.ts @@ -16,6 +16,18 @@ export interface RouteOperationToken { } export type RouteUpdateResult = "accepted" | "duplicate" | "stale"; +export type PeerSignalOwner = "pending" | "active"; + +export function exactPeerSignalOwner( + connectionId: string, + pendingConnectionId: string | null, + activeConnectionId: string | null, +): PeerSignalOwner | null { + if (connectionId === pendingConnectionId) { + return "pending"; + } + return connectionId === activeConnectionId ? "active" : null; +} export class MediaRouteTransition { private revision = -1; diff --git a/src/client/media/viewer-sfu-route.ts b/src/client/media/viewer-sfu-route.ts index 095b4f2..515c560 100644 --- a/src/client/media/viewer-sfu-route.ts +++ b/src/client/media/viewer-sfu-route.ts @@ -28,6 +28,9 @@ interface ViewerSubscriberSlot { connected: boolean; activated: boolean; mediaAvailable: boolean; + stream: MediaStream | null; + decodedFrame: boolean; + readySent: boolean; failed: boolean; activationToken: RouteOperationToken | null; } @@ -56,7 +59,6 @@ interface ViewerSfuRouteEvents { onSfuVideoAvailability?: (available: boolean) => void; onSfuUpdate?: (metrics: ConnectionMetrics | null) => void; onSfuState?: (state: "connected" | "reconnecting") => void; - onHealthySfu?: (revision: number) => void; send: (message: ClientMessage) => boolean; createSubscriber?: ( events: { @@ -74,12 +76,11 @@ export class ViewerSfuRoute { private pending: ViewerSubscriberSlot | null = null; private active: ViewerSubscriberSlot | null = null; private recovery: { revision: number; refreshed: boolean } | null = null; - private peerProbeRevision: number | null = null; - private failedPeerProbe: { revision: number; parentPeerId: string; connectionId: string } | null = null; - private healthySfuWindows: number | null = null; + private pendingPeerRevision: number | null = null; private transitionTail: Promise = Promise.resolve(); private resyncGeneration = 0; private resyncing = false; + private paused = false; private closed = false; constructor(private readonly events: ViewerSfuRouteEvents) {} @@ -93,26 +94,43 @@ export class ViewerSfuRoute { if (result === "stale") { return result; } - const rearmHealthySfu = - result === "duplicate" && - update.phase === "active" && - update.assignment.upstream.kind === "sfu"; + if (this.paused) { + this.discardPending(); + return result; + } + if (result === "duplicate") { + return result; + } if (previousRevision !== update.revision) { this.recovery = null; - this.peerProbeRevision = null; - this.failedPeerProbe = null; - this.healthySfuWindows = null; + if ( + update.phase !== "prepare" && + this.pendingPeerRevision !== null + ) { + this.events.preparePeer?.(null); + this.pendingPeerRevision = null; + } } if (update.phase === "prepare") { this.events.prepareChild?.(update.assignment.childPeerIds, update.revision); const mediaUpstream = this.route.getMediaAssignment()?.upstream; - if ( + const needsPeerCandidate = update.assignment.upstream.kind === "peer" && (mediaUpstream?.kind !== "peer" || - mediaUpstream.peerId !== update.assignment.upstream.peerId) - ) { - this.peerProbeRevision = update.revision; + mediaUpstream.peerId !== update.assignment.upstream.peerId); + const replacedPeerCandidate = this.pendingPeerRevision !== null; + if (replacedPeerCandidate) { + this.pendingPeerRevision = null; + this.events.preparePeer?.(null); + } + if (needsPeerCandidate && update.assignment.upstream.kind === "peer") { + this.pendingPeerRevision = update.revision; this.events.preparePeer?.(update.assignment, update.revision); + } else if ( + update.assignment.upstream.kind !== "peer" && + !replacedPeerCandidate + ) { + this.events.preparePeer?.(null); } if (this.pending?.revision !== update.revision) { this.clearPending(); @@ -127,26 +145,52 @@ export class ViewerSfuRoute { if (!token) { return result; } - if (this.peerProbeRevision !== update.revision) { + if (this.pendingPeerRevision !== update.revision) { this.preparePeer(update.assignment); } if (this.resyncing) { return result; } void this.queueActiveRoute(token, acknowledge); - if (rearmHealthySfu) { - this.armHealthySfuReselection(update.revision); - } return result; } + beginSelectedPeerCandidate(revision: number, parentPeerId: string): boolean { + const assignment = this.route.getPlannedAssignment(); + if ( + this.paused || + this.route.getRevision() !== revision || + this.route.getPhase() !== "prepare" || + assignment?.upstream.kind !== "peer" || + assignment.upstream.peerId !== parentPeerId + ) { + return false; + } + this.pendingPeerRevision = revision; + return true; + } + + setPaused(paused: boolean): void { + this.paused = paused; + if (paused) { + this.discardPending(); + } + } + + private discardPending(): void { + this.pendingPeerRevision = null; + this.events.preparePeer?.(null); + this.events.prepareChild?.(null); + this.clearPending(); + } + async resyncAuthoritative( update: RouteUpdateInput, ): Promise { if (this.closed) { return "stale"; } - this.peerProbeRevision = this.failedPeerProbe = null; + this.pendingPeerRevision = null; if (!this.active && !this.pending) { const result = this.accept(update, false); if (result !== "stale") { @@ -158,7 +202,6 @@ export class ViewerSfuRoute { this.resyncing = true; this.route.reset(); this.recovery = null; - this.healthySfuWindows = null; this.events.prepareChild?.(null); const pending = this.pending; const active = this.active; @@ -204,7 +247,7 @@ export class ViewerSfuRoute { async acceptConfig( message: Extract, ): Promise { - if (this.closed || !this.route.acceptsConfig(message.revision)) { + if (this.closed || this.paused || !this.route.acceptsConfig(message.revision)) { return; } const token = this.route.token(); @@ -215,15 +258,9 @@ export class ViewerSfuRoute { } if (this.active?.revision === message.revision && this.active.activated) { - if (phase === "prepare") { - this.ready(message.revision, "prepare"); - } return; } if (this.pending?.revision === message.revision && !this.pending.failed) { - if (this.pending.connected && phase === "prepare") { - this.ready(message.revision, "prepare"); - } return; } @@ -239,29 +276,30 @@ export class ViewerSfuRoute { this.handleVideoAvailability(slot, available); }, onStats: (metrics: ConnectionMetrics) => { - if (this.active === slot && !slot.failed) { - this.events.onSfuUpdate?.(metrics); - if (this.healthySfuWindows !== null && this.healthySfuWindows >= 0) { + if (this.pending === slot && !slot.failed) { + if ((metrics.intervalFramesDecoded ?? 0) > 0) { + slot.decodedFrame = true; if ( - (metrics.intervalPacketsReceived ?? 0) <= 0 || - (metrics.intervalFramesDecoded ?? 0) <= 0 + phase === "prepare" && + this.route.getRevision() === slot.revision && + this.route.getPhase() === "prepare" && + !slot.readySent ) { - this.healthySfuWindows = null; - } else if (++this.healthySfuWindows === 2) { - this.healthySfuWindows = null; - this.events.onHealthySfu?.(slot.revision); + slot.readySent = this.events.send({ + type: "route-ready", + revision: slot.revision, + phase: "prepare", + }); } + void this.queueTransition(() => this.promotePendingSfu(slot)); } + } else if (this.active === slot && !slot.failed) { + this.events.onSfuUpdate?.(metrics); } }, onState: (state: "connected" | "reconnecting") => { if (this.active === slot && !slot.failed) { this.events.onSfuState?.(state); - if (state === "reconnecting") { - this.healthySfuWindows = -1; - } else if (this.healthySfuWindows === -1) { - this.healthySfuWindows = 0; - } } }, onDisconnected: () => this.handleFailure(slot), @@ -275,6 +313,9 @@ export class ViewerSfuRoute { connected: false, activated: false, mediaAvailable: false, + stream: null, + decodedFrame: false, + readySent: false, failed: false, activationToken: null, }; @@ -296,9 +337,13 @@ export class ViewerSfuRoute { return; } slot.connected = true; - if (phase === "prepare") { - this.ready(message.revision, "prepare"); - } else if (!this.resyncing) { + if (!subscriber.activate()) { + this.handleFailure(slot); + await disconnectSubscriber(subscriber); + return; + } + slot.activated = true; + if (phase === "active" && !this.resyncing) { await this.queueActiveRoute(token, true); } } catch { @@ -316,25 +361,6 @@ export class ViewerSfuRoute { ); } - reportPeerProbeFailure(parentPeerId: string, connectionId: string, ready: boolean): boolean { - const revision = this.peerProbeRevision, phase = this.route.getPhase(); - const upstream = this.route.getPlannedAssignment()?.upstream; - if (revision === null || phase === null || - upstream?.kind !== "peer" || upstream.peerId !== parentPeerId) return false; - if (ready) this.failedPeerProbe = { revision, parentPeerId, connectionId }; - this.events.send({ type: "route-failed", revision, phase, connectionId }); - return true; - } - - armHealthySfuReselection(revision: number): void { - if ( - this.route.getRevision() === revision && - this.route.getPlannedAssignment()?.upstream.kind === "sfu" - ) { - this.healthySfuWindows = 0; - } - } - async disconnect(): Promise { if (this.closed) { return; @@ -344,9 +370,7 @@ export class ViewerSfuRoute { this.resyncing = false; this.route.reset(); this.recovery = null; - this.peerProbeRevision = null; - this.failedPeerProbe = null; - this.healthySfuWindows = null; + this.pendingPeerRevision = null; this.events.preparePeer?.(null); this.events.prepareChild?.(null); await this.queueTransition(async () => { @@ -399,31 +423,22 @@ export class ViewerSfuRoute { ) { this.clearPending(); this.active.revision = token.revision; - this.commitMedia(token, acknowledge); + this.commitMedia(token); return; } await this.activateSfu(token, acknowledge); return; } - const probeRevision = this.peerProbeRevision; - const failedProbe = this.failedPeerProbe; - if (failedProbe?.revision === token.revision && - assignment.upstream.kind === "peer" && - assignment.upstream.peerId === failedProbe.parentPeerId) { - this.failedPeerProbe = null; - this.events.send({ type: "route-failed", revision: token.revision, - phase: "active", connectionId: failedProbe.connectionId }); - return; - } + const pendingPeerRevision = this.pendingPeerRevision; const promoted = - probeRevision === token.revision + pendingPeerRevision === token.revision ? await this.events.activatePeer(assignment, token.revision) : false; - if (probeRevision === token.revision && promoted !== true) { + if (pendingPeerRevision === token.revision && promoted !== true) { return; } - this.peerProbeRevision = null; + this.pendingPeerRevision = null; this.clearPending(); if (this.active) { await this.retireActive(); @@ -438,7 +453,7 @@ export class ViewerSfuRoute { return; } this.activateChildren(assignment.childPeerIds, token.revision); - this.commitMedia(token, acknowledge); + this.commitMedia(token); }); } @@ -471,66 +486,57 @@ export class ViewerSfuRoute { return; } pending.activationToken = token; - try { - if (!pending.subscriber.activate()) { - await disconnectSubscriber(pending.subscriber); - this.handleFailure(pending); - return; - } - pending.activated = true; - } catch { - await disconnectSubscriber(pending.subscriber); - this.handleFailure(pending); - } + await this.promotePendingSfu(pending); } private handleStream(slot: ViewerSubscriberSlot, stream: MediaStream): void { if (this.closed || slot.failed || !slot.activated) { return; } + slot.stream = stream; if (this.active === slot) { const assignment = this.route.getMediaAssignment(); if (assignment?.upstream.kind === "sfu") { - const recovered = !slot.mediaAvailable; slot.mediaAvailable = true; this.events.onSfuStream(stream, assignment, false); - if (recovered) { - this.ready(slot.revision, "active"); - } } return; } + void this.queueTransition(() => this.promotePendingSfu(slot)); + } + + private async promotePendingSfu(slot: ViewerSubscriberSlot): Promise { const token = slot.activationToken; const assignment = this.route.getActiveAssignment(); if ( this.pending !== slot || + !slot.stream || + !slot.decodedFrame || !token || !this.route.owns(token, "active") || assignment?.upstream.kind !== "sfu" ) { return; } - this.pending = null; const previous = this.active; this.active = slot; if (!this.route.markMediaActive(token)) { this.active = previous; - void disconnectSubscriber(slot.subscriber); + await disconnectSubscriber(slot.subscriber); return; } this.recovery = null; slot.mediaAvailable = true; - this.events.onSfuStream(stream, assignment, true); + this.events.onSfuStream(slot.stream, assignment, true); if (previous && previous !== slot) { try { previous.subscriber.deactivate(); } catch { // Disconnect remains the fail-closed cleanup path. } - void disconnectSubscriber(previous.subscriber); + await disconnectSubscriber(previous.subscriber); } - this.ready(token.revision, "active"); } private handleVideoAvailability( @@ -552,9 +558,7 @@ export class ViewerSfuRoute { if (!changed) { return; } - if (available) { - this.ready(slot.revision, "active"); - } else { + if (!available) { this.events.send({ type: "route-media-unavailable", revision: slot.revision, @@ -562,18 +566,11 @@ export class ViewerSfuRoute { } } - private commitMedia(token: RouteOperationToken, acknowledge: boolean): void { + private commitMedia(token: RouteOperationToken): void { if (!this.route.markMediaActive(token)) { return; } this.recovery = null; - const assignment = this.route.getMediaAssignment(); - if ( - acknowledge && - (assignment?.upstream.kind !== "sfu" || this.active?.mediaAvailable === true) - ) { - this.ready(token.revision, "active"); - } } private clearPending(): void { @@ -658,9 +655,6 @@ export class ViewerSfuRoute { }); } - private ready(revision: number, phase: MediaRoutePhase): void { - this.events.send({ type: "route-ready", revision, phase }); - } } async function disconnectSubscriber( diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 679fc56..9bc222b 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -92,11 +92,7 @@ import { type HostSfuPublisherSnapshot, } from "../media/host-sfu-route"; import { - acceptHostSelectedChildrenRevision, - carryHostSelectedChild, - hostSelectedChildForConnection, HostProvisionalChild, - type ActiveSelectedHostChild, } from "../media/host-provisional-child"; import { ParentEdgeQualityEvidenceReporter, @@ -134,18 +130,10 @@ type ViewerQualityEvidence = Extract< ServerMessage, { type: "viewer-quality-evidence" } >; -type SelectedEdgeTurn = Extract< - ServerMessage, - { type: "selected-edge-turn"; edgeKind: "peer-selected" } ->; type HostRouteAssignment = Extract< ServerMessage, { type: "route-update" } >["assignment"]; -interface FailedActiveHostChild { - revision: number; - peerId: string; -} interface CaptureDetails { resolution: string; @@ -282,11 +270,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const iceConfigRef = useRef(null); const peersRef = useRef(new Map()); const retiredConnectionsRef = useRef(new Map()); - const selectedHostChildrenRef = useRef( - new Map(), - ); const hostProvisionalChildRef = useRef(null); - const failedActiveHostChildRef = useRef(null); const activeHostChildPeerIdsRef = useRef([]); const hostPeerIdRef = useRef(null); const viewerQualityEvidenceRef = useRef( @@ -424,10 +408,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { peersRef.current.forEach((peer) => peer.dispose()); peersRef.current.clear(); retiredConnectionsRef.current.clear(); - selectedHostChildrenRef.current.clear(); hostProvisionalChildRef.current?.discard(); hostProvisionalChildRef.current = null; - failedActiveHostChildRef.current = null; activeHostChildPeerIdsRef.current = []; hostPeerIdRef.current = null; viewerQualityEvidenceTimersRef.current.forEach((timer) => @@ -541,10 +523,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { peersRef.current.forEach((peer) => peer.dispose()); peersRef.current.clear(); retiredConnectionsRef.current.clear(); - selectedHostChildrenRef.current.clear(); hostProvisionalChildRef.current?.discard(); hostProvisionalChildRef.current = null; - failedActiveHostChildRef.current = null; activeHostChildPeerIdsRef.current = []; hostPeerIdRef.current = null; void hostSfuRouteRef.current?.disconnect(); @@ -878,6 +858,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } sharingPausedRef.current = nextPaused; setSharingPaused(nextPaused); + hostSfuRouteRef.current?.setPaused(nextPaused); + if (nextPaused) { + discardPreparedHostChild(); + } signalRef.current?.setSharingPaused(nextPaused); setNotice(nextPaused ? "音视频分享已暂停" : "音视频分享已恢复"); } @@ -908,7 +892,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { retiredConnectionsRef.current.set(peerId, peer.connectionId); peer.dispose(); } - selectedHostChildrenRef.current.delete(peerId); peersRef.current.delete(peerId); setPeerSnapshots((current) => { const next = new Map(current); @@ -945,7 +928,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { isCurrentGeneration(generation) && signalRef.current === signal ? signal.send({ type: "signal", targetPeerId, payload }) : false, - hasActivePeer: (peerId) => peersRef.current.has(peerId), + activeConnectionId: (peerId) => + peersRef.current.get(peerId)?.connectionId ?? null, onPromotedStreamFailure: (peer) => { if ( isCurrentGeneration(generation) && @@ -967,7 +951,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { revision, assignment, activeChildPeerIds: activeHostChildPeerIdsRef.current, - selectedPeerIds: selectedHostChildrenRef.current.keys(), maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN, iceConfig, stream, @@ -986,26 +969,16 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { revision, assignment, activeChildPeerIds, - selectedPeerIds: selectedHostChildrenRef.current.keys(), maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN, }) ?? { kind: "ordinary" as const }; if (activation.kind === "promote") { hostProvisionalChildRef.current = null; - failedActiveHostChildRef.current = null; + const previous = peersRef.current.get(activation.peerId); peersRef.current.set(activation.peerId, activation.peer); + previous?.dispose(); updatePeerSnapshot(activation.peer.getSnapshot()); - } else if (activation.kind === "failed") { - hostProvisionalChildRef.current = null; - failedActiveHostChildRef.current = { - revision, - peerId: activation.peerId, - }; - return; } else { discardPreparedHostChild(); - if (failedActiveHostChildRef.current?.revision !== revision) { - failedActiveHostChildRef.current = null; - } } activeHostChildPeerIdsRef.current = childPeerIds; } @@ -1014,7 +987,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { peerId: string, generation: number, attempt = 0, - selectedTurn?: SelectedEdgeTurn, ): Promise { if (!isCurrentGeneration(generation)) { return; @@ -1023,7 +995,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { // negotiation whose offer or answer may have been lost with the WebSocket. const existing = peersRef.current.get(peerId); if (existing) { - if (!selectedTurn && existing.isConnected()) { + if (existing.isConnected()) { return; } removePeer(peerId); @@ -1035,32 +1007,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } - const reportSelectedTurnFailure = (connectionId: string): void => { - const selected = hostSelectedChildForConnection( - selectedHostChildrenRef.current, - peerId, - connectionId, - ); - if ( - !selectedTurn || - !selected || - !isCurrentGeneration(generation) || - signalRef.current !== signal - ) { - return; - } - signal.send({ - type: "route-failed", - revision: selected.currentRouteRevision, - phase: "active", - connectionId, - }); - }; - let peer: HostPeer; peer = new HostPeer( peerId, - selectedTurn ? { iceServers: [selectedTurn.iceServer] } : iceConfig, + iceConfig, activeStream, qualitySettingsRef.current, { @@ -1073,17 +1023,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { isCurrentGeneration(generation) && peersRef.current.get(peerId) === peer ) { - if (selectedTurn && snapshot.connectionState === "failed") { - reportSelectedTurnFailure(peer.connectionId); - removePeer(peerId); - return; - } updatePeerSnapshot(snapshot); } }, }, - selectedTurn !== undefined, - selectedTurn?.newConnectionId, ); peersRef.current.set(peerId, peer); let started: boolean; @@ -1091,7 +1034,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { started = await peer.start(); } catch (error) { if (peersRef.current.get(peerId) === peer) { - reportSelectedTurnFailure(peer.connectionId); removePeer(peerId); } throw error; @@ -1100,9 +1042,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } - reportSelectedTurnFailure(peer.connectionId); removePeer(peerId); - if (selectedTurn || !isCurrentGeneration(generation) || attempt >= 1) { + if (!isCurrentGeneration(generation) || attempt >= 1) { return; } window.setTimeout(() => { @@ -1162,28 +1103,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { childPeerIds: string[], generation: number, ): void { - const failedPrepared = failedActiveHostChildRef.current; - const authoritativeChildPeerIds = - failedPrepared?.revision === activeRouteRevisionRef.current - ? childPeerIds.filter((peerId) => peerId !== failedPrepared.peerId) - : childPeerIds; - const selectedPeerIds: string[] = []; - for (const [peerId, selected] of selectedHostChildrenRef.current) { - const selectedIsCurrent = - selected.currentRouteRevision === activeRouteRevisionRef.current && - peersRef.current.get(peerId)?.connectionId === selected.connectionId; - if (selectedIsCurrent) { - selectedPeerIds.push(peerId); - } else { - selectedHostChildrenRef.current.delete(peerId); - } - } - const effectiveChildPeerIds = [ - ...new Set([...authoritativeChildPeerIds, ...selectedPeerIds]), - ]; reconcileBoundedMediaChildren( peersRef.current.keys(), - effectiveChildPeerIds, + childPeerIds, MAX_ENDPOINT_MEDIA_CHILDREN, removePeer, (peerId) => { @@ -1206,10 +1128,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } if (message.type === "authenticated" && message.role === "host") { discardPreparedHostChild(); - failedActiveHostChildRef.current = null; - for (const peerId of [...selectedHostChildrenRef.current.keys()]) { - removePeer(peerId); - } hostPeerIdRef.current = message.peerId; setViewerAccessUpdating(false); clearAllViewerQualityEvidence(); @@ -1275,40 +1193,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } return; } - const current = peersRef.current.get(message.viewerPeerId); + const iceConfig = iceConfigRef.current; + const activeStream = streamRef.current; if ( peerAssistedRef.current && message.parentPeerId === hostPeerIdRef.current && - current?.connectionId === message.newConnectionId && - carryHostSelectedChild( - selectedHostChildrenRef.current, - message.viewerPeerId, - message.newConnectionId, - message.revision, - ) + iceConfig && + activeStream ) { - return; - } - const oldConnectionId = - current?.connectionId ?? - retiredConnectionsRef.current.get(message.viewerPeerId); - if ( - peerAssistedRef.current && - message.parentPeerId === hostPeerIdRef.current && - message.revision === activeRouteRevisionRef.current && - Date.parse(message.expiresAt) > Date.now() && - oldConnectionId === message.oldConnectionId - ) { - if (current) { - removePeer(message.viewerPeerId); - } - selectedHostChildrenRef.current.set(message.viewerPeerId, { - peerId: message.viewerPeerId, - connectionId: message.newConnectionId, - currentRouteRevision: message.revision, - pendingCarryRevision: null, + hostProvisionalChildRef.current?.prepareSelectedTurn(message, { + iceConfig, + stream: activeStream, + profile: qualitySettingsRef.current, }); - void startPeer(message.viewerPeerId, generation, 0, message); } return; } @@ -1357,14 +1254,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } else { activatePreparedHostChild(message.revision, message.assignment); } - if (message.phase === "active") { - for (const peerId of acceptHostSelectedChildrenRevision( - selectedHostChildrenRef.current, - message.revision, - )) { - removePeer(peerId); - } - } if ( message.phase === "active" && message.revision !== activeRouteRevisionRef.current @@ -1392,10 +1281,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } return; } - if (message.type === "media-assignment") { + if (message.type === "media-assignment") { if (peerAssistedRef.current && !hostSfuRouteRef.current) { discardPreparedHostChild(); - failedActiveHostChildRef.current = null; activeHostChildPeerIdsRef.current = [ ...message.mediaAssignment.childPeerIds, ]; diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 5d104db..32a859e 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -60,6 +60,7 @@ import { ViewerQualityEvidenceReporter, } from "../media/viewer-quality-evidence"; import { ViewerMessageAuthority } from "../media/viewer-message-authority"; +import { exactPeerSignalOwner } from "../media/route-transition"; import { ViewerSfuRoute } from "../media/viewer-sfu-route"; import { applyViewerVolume, @@ -75,7 +76,6 @@ import type { import { limitMediaAssignment, MAX_ENDPOINT_MEDIA_CHILDREN, - retainSelectedMediaParent, viewerRestartMessage, viewerSignalMessage, } from "../webrtc/media-assignment"; @@ -95,20 +95,17 @@ type SelectedEdgeTurn = Extract< ServerMessage, { type: "selected-edge-turn"; edgeKind: "peer-selected" } >; -interface ActiveSelectedEdgeTurn { - grant: SelectedEdgeTurn; - currentRouteRevision: number; - pendingCarryRevision: number | null; -} interface SfuUpstreamState { connectionState: "connected" | "reconnecting"; metrics: ConnectionMetrics | null; } -interface PeerProbe { +interface PendingPeerRoute { revision: number; parentPeerId: string; peer: ViewerPeer | null; - ready: boolean; + readySent: boolean; + selectedTurn: SelectedEdgeTurn | null; + candidateConnectionId: string | null; stream: MediaStream | null; snapshot: PeerSnapshot | null; } @@ -272,12 +269,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { let preparedParentSignals: Array< Extract > = []; - let peerProbe: PeerProbe | null = null; - let retiredUpstream: { - parentPeerId: string; - connectionId: string; - } | null = null; - let selectedEdgeTurn: ActiveSelectedEdgeTurn | null = null; + let pendingPeer: PendingPeerRoute | null = null; const messageAuthority = new ViewerMessageAuthority(); let sfuStandbyPrewarmer: SfuStandbyPrewarmer | null = null; let relayChildEvidenceCurrent: ViewerQualityEvidencePresentation | null = @@ -438,20 +430,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { payload, }) : false, - onSelectedEdgeFailed: ( - _childPeerId, - connectionId, - revision, - ) => { - if (active && peerAssisted) { - signal.send({ - type: "route-failed", - revision, - phase: "active", - connectionId, - }); - } - }, onUpdate: (snapshot) => { if (active) { setRelaySnapshot(snapshot); @@ -501,35 +479,31 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } } - function discardPeerProbe(): void { - const probe = peerProbe; - peerProbe = null; + function discardPendingPeer(): void { + const probe = pendingPeer; + pendingPeer = null; prepareParent(null); probe?.peer?.dispose(); } - function peerProbeHasCurrentMedia( - probe: PeerProbe, - ): probe is PeerProbe & { peer: ViewerPeer; stream: MediaStream; snapshot: PeerSnapshot } { - const { snapshot, stream } = probe; + function pendingPeerHasDecodedFrame( + probe: PendingPeerRoute, + ): probe is PendingPeerRoute & { peer: ViewerPeer; snapshot: PeerSnapshot } { + const { snapshot } = probe; return ( snapshot !== null && - stream !== null && snapshot.peerId === probe.parentPeerId && probe.peer?.hasConnectionId(snapshot.connectionId) === true && - snapshot.connectionState === "connected" && - (snapshot.metrics.intervalPacketsReceived ?? 0) > 0 && - (snapshot.metrics.intervalFramesDecoded ?? 0) > 0 && - stream.getVideoTracks().some((track) => track.readyState === "live") + (snapshot.metrics.intervalFramesDecoded ?? 0) > 0 ); } - function provePeerProbe(): void { - const probe = peerProbe; - if (!probe || probe.ready || !peerProbeHasCurrentMedia(probe)) { + function provePendingPeer(): void { + const probe = pendingPeer; + if (!probe || probe.readySent || !pendingPeerHasDecodedFrame(probe)) { return; } - probe.ready = signal.send({ + probe.readySent = signal.send({ type: "route-ready", revision: probe.revision, phase: "prepare", @@ -547,28 +521,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } if (revision !== undefined) { - const probe = peerProbe; + const probe = pendingPeer; if ( !probe || - !probe.ready || + !probe.readySent || probe.revision !== revision || assignment.upstream.kind !== "peer" || assignment.upstream.peerId !== probe.parentPeerId || - !peerProbeHasCurrentMedia(probe) + !probe.peer || + !probe.stream || + !probe.snapshot ) { - if (probe?.ready && probe.snapshot) { - signal.send({ - type: "route-failed", - revision, - phase: "active", - connectionId: probe.snapshot.connectionId, - }); - } - discardPeerProbe(); + discardPendingPeer(); return false; } const previousPeer = peerRef.current; - peerProbe = null; + pendingPeer = null; prepareParent(null); peerRef.current = probe.peer; previousPeer?.dispose(); @@ -601,13 +569,15 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { if (!active || viewerSfuRoute !== route) { return; } - discardPeerProbe(); + discardPendingPeer(); if (revision !== undefined && assignment?.upstream.kind === "peer") { - peerProbe = { + pendingPeer = { revision, parentPeerId: assignment.upstream.peerId, peer: null, - ready: false, + readySent: false, + selectedTurn: null, + candidateConnectionId: null, stream: null, snapshot: null, }; @@ -666,8 +636,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); } }, - onHealthySfu: (revision) => - signal.send({ type: "sfu-reselection-ready", revision }), onSfuVideoAvailability: (available) => { if (!active || viewerSfuRoute !== route || available) { return; @@ -697,7 +665,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); setStatusText("正在播放"); if (initialVideoStream) { - if (!peerProbe) { + if (!pendingPeer) { peerRef.current?.dispose(); peerRef.current = null; setPeerSnapshot(null); @@ -713,7 +681,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { function clearViewerSfuRoute(): void { const route = viewerSfuRoute; - discardPeerProbe(); + discardPendingPeer(); viewerSfuRoute = null; setSfuUpstream(null); void route?.disconnect(); @@ -751,7 +719,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { function clearUpstreamState(): void { qualityEvidenceReporter.reset(); const peer = peerRef.current; - retiredUpstream = peer?.getConnectionIdentity() ?? retiredUpstream; peer?.dispose(); peerRef.current = null; setRemoteStream(null); @@ -781,49 +748,66 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { reconcileRelayChildren(previousChildPeerIds); } - function ensurePeerProbe(): ViewerPeer | null { - const probe = peerProbe; + function ensurePendingPeerRoute(): ViewerPeer | null { + const probe = pendingPeer; if (!probe || !currentIceConfig) return null; if (probe.peer) return probe.peer; - const peer: ViewerPeer = new ViewerPeer(currentIceConfig, { - sendSignal: (targetPeerId, payload) => - signal.send(viewerSignalMessage(peerAssisted, targetPeerId, payload)), - sendRestartRequest: () => false, - onStream: (stream) => { - if (peerProbe === probe) { - probe.stream = stream; - provePeerProbe(); - } else if (active && peerRef.current === peer) { - setRemoteStream(stream); - ensureViewerRelay()?.setStream(stream); - setStatusText("正在播放"); - } + const peer: ViewerPeer = new ViewerPeer( + probe.selectedTurn + ? { iceServers: [probe.selectedTurn.iceServer] } + : currentIceConfig, + { + sendSignal: (targetPeerId, payload) => + signal.send(viewerSignalMessage(peerAssisted, targetPeerId, payload)), + sendRestartRequest: () => false, + onStream: (stream) => { + if (pendingPeer === probe) { + probe.stream = stream; + provePendingPeer(); + } else if (active && peerRef.current === peer) { + setRemoteStream(stream); + ensureViewerRelay()?.setStream(stream); + setStatusText("正在播放"); + } + }, + onUpdate: (snapshot) => { + if (pendingPeer === probe) { + probe.snapshot = snapshot; + probe.candidateConnectionId = snapshot.connectionId; + provePendingPeer(); + } else if (active && peerRef.current === peer) { + qualityEvidenceReporter.offer(snapshot, currentRouteRevision); + setPeerSnapshot(snapshot); + setStatusText( + snapshot.connectionState === "connected" + ? "已连接" + : "正在恢复连接", + ); + } + }, + onRecoveryExhausted: (parentPeerId, connectionId): boolean => { + if (pendingPeer === probe && peer.hasConnectionId(connectionId)) { + signal.send({ + type: "route-failed", + revision: probe.revision, + phase: "prepare", + connectionId, + }); + probe.candidateConnectionId = connectionId; + probe.peer = null; + probe.stream = null; + probe.snapshot = null; + probe.readySent = false; + peer.dispose(); + return true; + } + return peerRef.current === peer && viewerSfuRoute + ? viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId) + : true; + }, }, - onUpdate: (snapshot) => { - if (peerProbe === probe) { - probe.snapshot = snapshot; - provePeerProbe(); - } else if (active && peerRef.current === peer) { - qualityEvidenceReporter.offer(snapshot, currentRouteRevision); - setPeerSnapshot(snapshot); - setStatusText( - snapshot.connectionState === "connected" - ? "已连接" - : "正在恢复连接", - ); - } - }, - onRecoveryExhausted: (parentPeerId, connectionId): boolean => { - if (peerProbe === probe && peer.hasConnectionId(connectionId)) { - viewerSfuRoute?.reportPeerProbeFailure(parentPeerId, connectionId, probe.ready); - discardPeerProbe(); - return true; - } - return peerRef.current === peer && viewerSfuRoute - ? viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId) - : true; - }, - }); + probe.selectedTurn !== null, + ); probe.peer = peer; return peer; } @@ -836,9 +820,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return null; } const peer = new ViewerPeer( - selectedEdgeTurn - ? { iceServers: [selectedEdgeTurn.grant.iceServer] } - : currentIceConfig, + currentIceConfig, { sendSignal: (targetPeerId, payload) => signal.send( @@ -885,21 +867,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } }, onRecoveryExhausted: (parentPeerId, connectionId) => { - if ( - selectedEdgeTurn?.grant.parentPeerId === parentPeerId && - selectedEdgeTurn.grant.newConnectionId === connectionId - ) { - signal.send({ - type: "route-failed", - revision: selectedEdgeTurn.currentRouteRevision, - phase: "active", - connectionId, - }); - selectedEdgeTurn = null; - clearUpstreamState(); - setStatusText("无法建立媒体连接"); - return true; - } if (viewerSfuRoute) { return viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId); } @@ -907,7 +874,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return true; }, }, - selectedEdgeTurn !== null, ); peerRef.current = peer; return peer; @@ -918,12 +884,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { authorityToken: number, ): Promise { if (message.type === "authenticated") { - const hadSelectedUpstream = selectedEdgeTurn !== null; - selectedEdgeTurn = null; - if (hadSelectedUpstream) { - clearUpstreamState(); - } - viewerRelay?.clearSelectedEdgeTurn(); viewerAuthenticated = true; setAccessState("ready"); setViewerPasswordDraft(""); @@ -950,7 +910,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ? message.routeRevision : 0; if (nextRouteRevision !== currentRouteRevision) { - viewerRelay?.clearSelectedEdgeTurn(); clearRelayChildEvidence(); } currentRouteRevision = nextRouteRevision; @@ -970,11 +929,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { currentIceConfig = message.iceConfig; currentHostOnline = message.hostOnline; setHostOnline(message.hostOnline); - setHostPaused(message.hostPaused ?? false); + const sharingPaused = message.hostPaused ?? false; + setHostPaused(sharingPaused); if (nextPeerAssisted && "qualitySettings" in message) { currentQualitySettings = message.qualitySettings; void viewerRelay?.updateProfile(currentQualitySettings); - await ensureViewerSfuRoute().resyncAuthoritative( + const route = ensureViewerSfuRoute(); + route.setPaused(sharingPaused); + await route.resyncAuthoritative( { revision: message.routeRevision, phase: "active", @@ -984,9 +946,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { if (!active || !messageAuthority.owns(authorityToken)) { return; } - if (message.routeAssignment.upstream.kind === "sfu") { - ensureViewerSfuRoute().armHealthySfuReselection(message.routeRevision); - } } if ( !message.hostOnline && @@ -1031,54 +990,61 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } if (message.parentPeerId === currentPeerId) { - ensureViewerRelay()?.startSelectedEdgeTurn( + ensureViewerRelay()?.prepareSelectedEdgeTurn( message, currentPeerId, - currentRouteRevision, + message.revision, ); return; } + let probe = pendingPeer; if ( - selectedEdgeTurn?.grant.parentPeerId === message.parentPeerId && - selectedEdgeTurn.grant.viewerPeerId === message.viewerPeerId && - selectedEdgeTurn.grant.newConnectionId === message.newConnectionId && - message.revision >= selectedEdgeTurn.currentRouteRevision - ) { - selectedEdgeTurn.currentRouteRevision = message.revision; - selectedEdgeTurn.pendingCarryRevision = message.revision; - return; - } - if ( - message.revision !== currentRouteRevision || - Date.parse(message.expiresAt) <= Date.now() + !viewerSfuRoute?.beginSelectedPeerCandidate( + message.revision, + message.parentPeerId, + ) ) { return; } - const currentIdentity = - peerRef.current?.getConnectionIdentity() ?? retiredUpstream; + if (!probe) { + probe = pendingPeer = { + revision: message.revision, + parentPeerId: message.parentPeerId, + peer: null, + readySent: false, + selectedTurn: null, + candidateConnectionId: null, + stream: null, + snapshot: null, + }; + prepareParent(message.parentPeerId); + } + if (probe.selectedTurn?.newConnectionId === message.newConnectionId) { + return; + } + const activeIdentity = peerRef.current?.getConnectionIdentity(); + const oldConnectionId = + probe?.candidateConnectionId ?? + probe?.peer?.getConnectionIdentity()?.connectionId ?? + (activeIdentity?.parentPeerId === message.parentPeerId + ? activeIdentity.connectionId + : null); if ( message.viewerPeerId !== currentPeerId || - currentIdentity?.parentPeerId !== message.parentPeerId || - currentIdentity.connectionId !== message.oldConnectionId + probe?.revision !== message.revision || + probe.parentPeerId !== message.parentPeerId || + Date.parse(message.expiresAt) <= Date.now() || + oldConnectionId !== message.oldConnectionId ) { return; } - selectedEdgeTurn = { - grant: message, - currentRouteRevision: message.revision, - pendingCarryRevision: null, - }; - clearViewerSfuRoute(); - clearUpstreamState(); - acceptAssignedRoute(message.revision, { - kind: "peer", - peerId: message.parentPeerId, - }); - currentAssignment = { - parentPeerId: message.parentPeerId, - childPeerIds: currentAssignment.childPeerIds, - }; - setStatusText("正在恢复连接"); + probe.peer?.dispose(); + probe.peer = null; + probe.readySent = false; + probe.selectedTurn = message; + probe.candidateConnectionId = null; + probe.stream = null; + probe.snapshot = null; return; } if (message.type === "route-update") { @@ -1087,39 +1053,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { message.phase === "active" && message.revision >= currentRouteRevision ) { - viewerRelay?.acceptActiveRevision(message.revision); - if (selectedEdgeTurn) { - if ( - selectedEdgeTurn.pendingCarryRevision === message.revision - ) { - selectedEdgeTurn.pendingCarryRevision = null; - } else { - selectedEdgeTurn = null; - clearUpstreamState(); - } - } if (message.revision !== currentRouteRevision) { clearRelayChildEvidence(); } currentRouteRevision = message.revision; - if (selectedEdgeTurn) { - applyMediaAssignment( - { - parentPeerId: selectedEdgeTurn.grant.parentPeerId, - childPeerIds: message.assignment.childPeerIds, - }, - true, - ); - acceptAssignedRoute( - message.revision, - { - kind: "peer", - peerId: selectedEdgeTurn.grant.parentPeerId, - }, - "active", - ); - return; - } } const result = ensureViewerSfuRoute().accept(message); if (result !== "stale") { @@ -1144,15 +1081,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } if (message.type === "media-assignment") { if (peerAssisted && !viewerSfuRoute) { - applyMediaAssignment( - selectedEdgeTurn - ? retainSelectedMediaParent( - message.mediaAssignment, - selectedEdgeTurn.grant.parentPeerId, - ) - : message.mediaAssignment, - selectedEdgeTurn !== null, - ); + applyMediaAssignment(message.mediaAssignment); } return; } @@ -1164,8 +1093,34 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } if (message.type === "signal") { - if (peerAssisted && peerProbe?.parentPeerId === message.fromPeerId) { - await ensurePeerProbe()?.acceptSignal(message.fromPeerId, message.payload); + if (peerAssisted && pendingPeer?.parentPeerId === message.fromPeerId) { + if (currentAssignment.parentPeerId !== message.fromPeerId) { + await ensurePendingPeerRoute()?.acceptSignal( + message.fromPeerId, + message.payload, + ); + return; + } + const activeIdentity = peerRef.current?.getConnectionIdentity(); + const owner = exactPeerSignalOwner( + message.payload.connectionId, + pendingPeer.selectedTurn?.newConnectionId ?? + pendingPeer.candidateConnectionId, + activeIdentity?.parentPeerId === message.fromPeerId + ? activeIdentity.connectionId + : null, + ); + if (owner === "pending") { + await ensurePendingPeerRoute()?.acceptSignal( + message.fromPeerId, + message.payload, + ); + } else if (owner === "active") { + await peerRef.current?.acceptSignal( + message.fromPeerId, + message.payload, + ); + } return; } if ( @@ -1218,6 +1173,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { currentHostOnline = message.online; setHostOnline(message.online); setHostPaused(message.paused); + viewerSfuRoute?.setPaused(message.paused); if (!message.online && !peerRef.current?.isConnected()) { setStatusText("等待开始分享"); } else if (message.online && !peerRef.current?.isConnected()) { @@ -1230,7 +1186,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } if (message.type === "sharing-stopped") { - selectedEdgeTurn = null; setSfuStandbyUrl(null); setAssignedRoute(null); currentHostOnline = false; diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index aea4cb5..00d69c1 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -14,41 +14,27 @@ import { MAX_ENDPOINT_MEDIA_CHILDREN } from "./media-assignment"; interface ViewerRelayEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; onUpdate?: (snapshot: PeerSnapshot | null) => void; - onSelectedEdgeFailed?: ( - childPeerId: string, - connectionId: string, - revision: number, - ) => void; } type SelectedEdgeTurn = Extract< ServerMessage, { type: "selected-edge-turn"; edgeKind: "peer-selected" } >; -interface SelectedChildConnection { - peerId: string; - connectionId: string; - revision: number; - pendingCarryRevision: number | null; -} interface PreparedChild { revision: number; childPeerId: string; peer: HostPeer; + failed: boolean; + replacesConnectionId: string | null; } -type FailedPreparedChild = Omit & { connectionId: string }; export class ViewerRelay { private childPeerIds: string[] = []; private stream: MediaStream | null = null; private readonly peers = new Map(); private readonly snapshots = new Map(); - private readonly retiredConnections = new Map(); - private readonly selectedChildConnections = new Map< - string, - SelectedChildConnection - >(); private preparedChild: PreparedChild | null = null; - private failedPreparedChild: FailedPreparedChild | null = null; + private preparedRevision: number | null = null; + private plannedChildPeerIds: string[] = []; private syncQueue = Promise.resolve(); private disposed = false; @@ -68,142 +54,104 @@ export class ViewerRelay { : null; } - startSelectedEdgeTurn( + prepareSelectedEdgeTurn( message: SelectedEdgeTurn, parentPeerId: string, routeRevision: number, now = Date.now(), ): boolean { const stream = this.stream; - const currentPeer = this.peers.get(message.viewerPeerId); - const connectionId = - currentPeer?.connectionId ?? - this.retiredConnections.get(message.viewerPeerId); - const selected = this.selectedChildConnections.get(message.viewerPeerId); - if ( - selected?.peerId === message.viewerPeerId && - selected.connectionId === message.newConnectionId && - currentPeer?.connectionId === selected.connectionId && - message.parentPeerId === parentPeerId && - message.revision >= selected.revision - ) { - selected.revision = message.revision; - selected.pendingCarryRevision = message.revision; - return true; - } if ( this.disposed || !stream || message.parentPeerId !== parentPeerId || message.revision !== routeRevision || Date.parse(message.expiresAt) <= now || - connectionId !== message.oldConnectionId || - (!this.childPeerIds.includes(message.viewerPeerId) && - this.childPeerIds.length >= MAX_ENDPOINT_MEDIA_CHILDREN) + this.preparedRevision !== routeRevision || + !this.plannedChildPeerIds.includes(message.viewerPeerId) ) { return false; } - this.disposePeer(message.viewerPeerId); - if (!this.childPeerIds.includes(message.viewerPeerId)) { - this.childPeerIds.push(message.viewerPeerId); + const prepared = this.preparedChild; + if (prepared && prepared.childPeerId !== message.viewerPeerId) { + return false; } - this.selectedChildConnections.set(message.viewerPeerId, { - peerId: message.viewerPeerId, - connectionId: message.newConnectionId, - revision: message.revision, - pendingCarryRevision: null, - }); - this.startPeer(message.viewerPeerId, stream, 0, message); + if (prepared?.peer.connectionId === message.newConnectionId) { + return !prepared.failed; + } + const oldConnectionId = + prepared?.peer.connectionId ?? + this.peers.get(message.viewerPeerId)?.connectionId; + if (oldConnectionId !== message.oldConnectionId) { + return false; + } + this.discardPreparedPeer(); + this.startPreparedChild(message.revision, message.viewerPeerId, stream, message); return true; } - clearSelectedEdgeTurn(): void { - for (const peerId of [...this.selectedChildConnections.keys()]) { - this.clearSelectedChildConnection(peerId); - } - } - - acceptActiveRevision(revision: number): void { - for (const [peerId, selected] of [...this.selectedChildConnections]) { - if (selected.pendingCarryRevision === revision) { - selected.pendingCarryRevision = null; - } else { - this.clearSelectedChildConnection(peerId); - } - } - } - prepareChild( revision: number, plannedChildPeerIds: readonly string[], ): boolean { const stream = this.stream; const planned = [...new Set(plannedChildPeerIds)]; - const childPeerId = planned.find( - (peerId) => !this.childPeerIds.includes(peerId), - ); if ( this.disposed || !stream || planned.length !== plannedChildPeerIds.length || - planned.length !== this.childPeerIds.length + 1 || + planned.length < this.childPeerIds.length || + planned.length > this.childPeerIds.length + 1 || planned.length > MAX_ENDPOINT_MEDIA_CHILDREN || - this.childPeerIds.some((peerId) => !planned.includes(peerId)) || - !childPeerId || - this.peers.has(childPeerId) + this.childPeerIds.some((peerId) => !planned.includes(peerId)) ) { this.discardPreparedChild(); return false; } if ( - this.preparedChild?.revision === revision && - this.preparedChild.childPeerId === childPeerId + this.preparedRevision === revision && + samePeerIds(this.plannedChildPeerIds, planned) ) { - return true; - } - if ( - this.failedPreparedChild?.revision === revision && - this.failedPreparedChild.childPeerId === childPeerId - ) { - return false; + return this.preparedChild ? !this.preparedChild.failed : true; } this.discardPreparedChild(); - const peer = this.createPeer(childPeerId, stream); - this.preparedChild = { revision, childPeerId, peer }; - void peer - .start() - .catch(() => false) - .then((started) => { - if (!started && this.preparedChild?.peer === peer) { - this.failPreparedChild(peer); - } - }); + this.preparedRevision = revision; + this.plannedChildPeerIds = planned; + const childPeerId = planned.find( + (peerId) => !this.childPeerIds.includes(peerId), + ); + if (!childPeerId) { + return true; + } + this.startPreparedChild(revision, childPeerId, stream); return true; } activateChildren(revision: number, childPeerIds: readonly string[]): void { const prepared = this.preparedChild; - const failed = this.failedPreparedChild; const extendsActive = childPeerIds.length === this.childPeerIds.length + 1 && this.childPeerIds.every((peerId) => childPeerIds.includes(peerId)); + const replacedPeer = prepared + ? this.peers.get(prepared.childPeerId) ?? null + : null; + const replacesActive = + replacedPeer?.connectionId === prepared?.replacesConnectionId; let promotedPeerId: string | null = null; if ( prepared?.revision === revision && + !prepared.failed && childPeerIds.includes(prepared.childPeerId) && - extendsActive + (extendsActive || replacesActive) ) { this.preparedChild = null; + this.preparedRevision = null; + this.plannedChildPeerIds = []; this.peers.set(prepared.childPeerId, prepared.peer); + replacedPeer?.dispose(); this.snapshots.set(prepared.childPeerId, prepared.peer.getSnapshot()); promotedPeerId = prepared.childPeerId; - } else if ( - failed?.revision === revision && - childPeerIds.includes(failed.childPeerId) && - extendsActive - ) { - return; } else { this.discardPreparedChild(); } @@ -211,20 +159,19 @@ export class ViewerRelay { } discardPreparedChild(): void { + this.preparedRevision = null; + this.plannedChildPeerIds = []; + this.discardPreparedPeer(); + } + + private discardPreparedPeer(): void { const prepared = this.preparedChild; this.preparedChild = null; - this.failedPreparedChild = null; prepared?.peer.dispose(); } setChildren(childPeerIds: readonly string[]): void { - const failedChildPeerId = this.failedPreparedChild?.childPeerId; - this.reconcileChildren( - failedChildPeerId - ? childPeerIds.filter((peerId) => peerId !== failedChildPeerId) - : childPeerIds, - null, - ); + this.reconcileChildren(childPeerIds, null); } private reconcileChildren( @@ -238,20 +185,6 @@ export class ViewerRelay { 0, MAX_ENDPOINT_MEDIA_CHILDREN, ); - for (const selectedPeerId of this.selectedChildConnections.keys()) { - if (!nextChildPeerIds.includes(selectedPeerId)) { - if (nextChildPeerIds.length >= MAX_ENDPOINT_MEDIA_CHILDREN) { - const removableIndex = nextChildPeerIds.findLastIndex( - (peerId) => !this.selectedChildConnections.has(peerId), - ); - if (removableIndex < 0) { - continue; - } - nextChildPeerIds.splice(removableIndex, 1); - } - nextChildPeerIds.push(selectedPeerId); - } - } for (const childPeerId of this.childPeerIds) { if (!nextChildPeerIds.includes(childPeerId)) { this.disposePeer(childPeerId); @@ -260,9 +193,6 @@ export class ViewerRelay { this.childPeerIds = nextChildPeerIds; for (const childPeerId of this.childPeerIds) { const peer = this.peers.get(childPeerId); - if (this.selectedChildConnections.has(childPeerId)) { - continue; - } if ( this.stream && childPeerId !== promotedPeerId && @@ -310,6 +240,7 @@ export class ViewerRelay { ): number { const prepared = this.preparedChild; return prepared?.childPeerId === fromPeerId && + !prepared.failed && prepared.peer.connectionId === connectionId ? prepared.revision : activeRevision; @@ -323,6 +254,7 @@ export class ViewerRelay { const prepared = this.preparedChild; if ( prepared?.revision === routeRevision && + !prepared.failed && prepared.childPeerId === fromPeerId && prepared.peer.connectionId === payload.connectionId ) { @@ -330,14 +262,10 @@ export class ViewerRelay { return true; } const peer = this.peers.get(fromPeerId); - const selected = this.selectedChildConnections.get(fromPeerId); if ( !this.childPeerIds.includes(fromPeerId) || !peer || - peer.connectionId !== payload.connectionId || - (selected?.peerId === fromPeerId && - (selected.connectionId !== payload.connectionId || - selected.revision !== routeRevision)) + peer.connectionId !== payload.connectionId ) { return false; } @@ -386,7 +314,6 @@ export class ViewerRelay { stop(): void { this.stream = null; this.discardPreparedChild(); - this.clearSelectedEdgeTurn(); this.disposePeers(); } @@ -397,11 +324,34 @@ export class ViewerRelay { this.disposed = true; this.stream = null; this.discardPreparedChild(); - this.selectedChildConnections.clear(); this.childPeerIds = []; this.disposePeers(); } + private startPreparedChild( + revision: number, + childPeerId: string, + stream: MediaStream, + selectedTurn?: SelectedEdgeTurn, + ): void { + const peer = this.createPeer(childPeerId, stream, selectedTurn); + this.preparedChild = { + revision, + childPeerId, + peer, + failed: false, + replacesConnectionId: selectedTurn?.oldConnectionId ?? null, + }; + void peer + .start() + .catch(() => false) + .then((started) => { + if (!started && this.preparedChild?.peer === peer) { + this.failPreparedChild(peer); + } + }); + } + private async syncStream( stream: MediaStream, replacePrepared: boolean, @@ -484,7 +434,6 @@ export class ViewerRelay { childPeerId: string, stream: MediaStream, attempt = 0, - selectedTurn?: SelectedEdgeTurn, ): void { if ( this.disposed || @@ -495,7 +444,7 @@ export class ViewerRelay { return; } - const peer = this.createPeer(childPeerId, stream, selectedTurn); + const peer = this.createPeer(childPeerId, stream); this.peers.set(childPeerId, peer); void peer .start() @@ -504,10 +453,6 @@ export class ViewerRelay { if (started || this.peers.get(childPeerId) !== peer) { return; } - if (selectedTurn) { - this.failSelectedChild(childPeerId, peer.connectionId); - return; - } this.disposePeer(childPeerId); if ( attempt < 1 && @@ -557,10 +502,6 @@ export class ViewerRelay { snapshot.peerId === childPeerId && snapshot.connectionId === peer.connectionId ) { - if (selectedTurn && snapshot.connectionState === "failed") { - this.failSelectedChild(childPeerId, peer.connectionId); - return; - } this.snapshots.set(childPeerId, { ...snapshot, metrics: { ...snapshot.metrics }, @@ -575,34 +516,12 @@ export class ViewerRelay { return peer; } - private failSelectedChild(childPeerId: string, connectionId: string): void { - const selected = this.selectedChildConnections.get(childPeerId); - if ( - selected?.peerId !== childPeerId || - selected.connectionId !== connectionId - ) { - return; - } - this.events.onSelectedEdgeFailed?.( - childPeerId, - connectionId, - selected.revision, - ); - this.disposePeer(childPeerId); - } - private failPreparedChild(peer: HostPeer): void { const prepared = this.preparedChild; if (prepared?.peer !== peer) { return; } - this.preparedChild = null; - const { revision, childPeerId } = prepared; - this.failedPreparedChild = { - revision, - childPeerId, - connectionId: peer.connectionId, - }; + prepared.failed = true; peer.dispose(); } @@ -614,39 +533,17 @@ export class ViewerRelay { private disposePeer(childPeerId: string): void { const peer = this.peers.get(childPeerId); - if ( - peer && - this.selectedChildConnections.get(childPeerId)?.connectionId === - peer.connectionId - ) { - this.selectedChildConnections.delete(childPeerId); - this.childPeerIds = this.childPeerIds.filter( - (peerId) => peerId !== childPeerId, - ); - } - if (peer) { - this.retiredConnections.delete(childPeerId); - this.retiredConnections.set(childPeerId, peer.connectionId); - while (this.retiredConnections.size > MAX_ENDPOINT_MEDIA_CHILDREN) { - const oldestPeerId = this.retiredConnections.keys().next().value; - if (typeof oldestPeerId === "string") { - this.retiredConnections.delete(oldestPeerId); - } - } - } this.peers.delete(childPeerId); this.snapshots.delete(childPeerId); this.events.onUpdate?.(this.getSnapshot()); peer?.dispose(); } - private clearSelectedChildConnection(childPeerId: string): void { - if (!this.selectedChildConnections.delete(childPeerId)) { - return; - } - this.childPeerIds = this.childPeerIds.filter( - (peerId) => peerId !== childPeerId, - ); - this.disposePeer(childPeerId); - } +} + +function samePeerIds(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((peerId, index) => peerId === right[index]) + ); } diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index d52921d..1f2f79a 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -1005,7 +1005,6 @@ function hostProvisionalInput( overrides: { activeChildPeerIds?: string[]; publicationGeneration?: string | null; - selectedPeerIds?: readonly string[]; } = {}, ) { return { @@ -1015,7 +1014,6 @@ function hostProvisionalInput( overrides.publicationGeneration ?? null, ), activeChildPeerIds: overrides.activeChildPeerIds ?? [], - selectedPeerIds: overrides.selectedPeerIds ?? [], maxMediaEdges: 2, iceConfig: { iceServers: [] }, stream, @@ -1104,7 +1102,6 @@ describe("Host provisional child runtime ownership", () => { revision: 9, assignment: hostAssignment([]), activeChildPeerIds: [], - selectedPeerIds: [], maxMediaEdges: 2, })).toEqual({ kind: "ordinary" }); expect(second.connectionState).toBe("closed"); @@ -1116,124 +1113,70 @@ describe("Host provisional child runtime ownership", () => { expect(FakePeerConnection.activeCount).toBe(0); }); - it("retains failed connection identity until authoritative activation", async () => { - const signals: Array<{ connectionId: string }> = []; - const owner = new HostProvisionalChild({ - sendSignal: (_peerId, payload) => { - signals.push({ connectionId: payload.connectionId }); - return false; - }, - }); - const stream = createStream(createTrack("video", "host-failed-video"), null); - const input = hostProvisionalInput(11, ["failed-probe"], stream); + it("updates the prepared stream before promotion", async () => { + const owner = new HostProvisionalChild({ sendSignal: () => true }); + const initial = createStream(createTrack("video", "initial-video"), null); + const replacement = createTrack("video", "replacement-video"); + const input = hostProvisionalInput(12, ["stream-child"], initial); expect(owner.prepare(input)).toBe(true); - await vi.waitFor(() => expect(FakePeerConnection.activeCount).toBe(0)); - expect(signals).toHaveLength(1); - const instanceCount = FakePeerConnection.instances.length; - expect(owner.prepare(input)).toBe(false); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); + const connection = FakePeerConnection.latest!; + await expect( + owner.replaceStream(createStream(replacement, null)), + ).resolves.toBe(true); + expect(connection.senders[0]!.track).toBe(replacement); - expect(owner.activate(input)).toEqual({ - kind: "failed", - peerId: "failed-probe", - connectionId: signals[0]!.connectionId, + const activation = owner.activate({ + revision: input.revision, + assignment: input.assignment, + activeChildPeerIds: [], + maxMediaEdges: 2, }); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); + expect(activation.kind).toBe("promote"); + if (activation.kind === "promote") { + activation.peer.dispose(); + } }); - it("replaces the prepared stream in place and tombstones a failed replacement", async () => { - const signals: Array<{ connectionId: string }> = []; + it("replaces an active child with selected TURN only on active promotion", async () => { + const signals: Array<{ peerId: string; connectionId: string }> = []; + const activeConnection = { id: "active-connection" }; const owner = new HostProvisionalChild({ - sendSignal: (_peerId, payload) => { - signals.push({ connectionId: payload.connectionId }); + sendSignal: (peerId, payload) => { + signals.push({ peerId, connectionId: payload.connectionId }); return true; }, + activeConnectionId: () => activeConnection.id, }); - const initial = createStream(createTrack("video", "host-initial-video"), null); - const input = hostProvisionalInput(12, ["stream-probe"], initial); - expect(owner.prepare(input)).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(1)); - const preparedConnection = FakePeerConnection.latest!; - const preparedConnectionId = signals[0]!.connectionId; - const instanceCount = FakePeerConnection.instances.length; - - const replacementVideo = createTrack("video", "host-replacement-video"); - await expect( - owner.replaceStream(createStream(replacementVideo, null)), - ).resolves.toBe(true); - expect(preparedConnection.senders[0]!.track).toBe(replacementVideo); - const activation = owner.activate(input); - expect(activation.kind).toBe("promote"); - if (activation.kind !== "promote") { - throw new Error("expected stream-updated Host probe to promote"); - } - expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(FakePeerConnection.latest).toBe(preparedConnection); - expect(activation.peer.connectionId).toBe(preparedConnectionId); - activation.peer.dispose(); - - const failedSignals: Array<{ connectionId: string }> = []; - const failedOwner = new HostProvisionalChild({ - sendSignal: (_peerId, payload) => { - failedSignals.push({ connectionId: payload.connectionId }); - return true; - }, - }); - const failedInput = hostProvisionalInput( - 13, - ["failed-stream-probe"], - initial, - ); - expect(failedOwner.prepare(failedInput)).toBe(true); - await vi.waitFor(() => expect(failedSignals).toHaveLength(1)); - const failedConnection = FakePeerConnection.latest!; - const failedConnectionId = failedSignals[0]!.connectionId; - const failedInstanceCount = FakePeerConnection.instances.length; - failedConnection.senders[0]!.failNextReplace = true; - await expect( - failedOwner.replaceStream( - createStream(createTrack("video", "host-failed-replacement"), null), - ), - ).resolves.toBe(false); - expect(failedConnection.connectionState).toBe("closed"); - const failedActivation = failedOwner.activate(failedInput); - expect(failedActivation).toMatchObject({ - kind: "failed", - peerId: "failed-stream-probe", - connectionId: failedConnectionId, - }); - expect(FakePeerConnection.instances).toHaveLength(failedInstanceCount); - - const promotedFailure = vi.fn(); - const racingSignals: string[] = []; - const racingOwner = new HostProvisionalChild({ - sendSignal: (_peerId, payload) => { - racingSignals.push(payload.connectionId); - return true; - }, - onPromotedStreamFailure: promotedFailure, - }); - const racingInput = hostProvisionalInput( + const input = hostProvisionalInput( 14, - ["racing-stream-probe"], - initial, + ["selected-child"], + createStream(createTrack("video", "selected-video"), null), + { activeChildPeerIds: ["selected-child"] }, ); - expect(racingOwner.prepare(racingInput)).toBe(true); - await vi.waitFor(() => expect(racingSignals).toHaveLength(1)); - const racingConnection = FakePeerConnection.latest!; - racingConnection.senders[0]!.failNextReplace = true; - const racingReplacement = racingOwner.replaceStream( - createStream(createTrack("video", "host-racing-replacement"), null), - ); - const racingActivation = racingOwner.activate(racingInput); - expect(racingActivation.kind).toBe("promote"); - if (racingActivation.kind !== "promote") { - throw new Error("expected racing Host probe to promote"); + expect(owner.prepare(input)).toBe(true); + expect(FakePeerConnection.latest).toBeNull(); + expect(owner.prepareSelectedTurn({ + ...selectedEdgeTurn( + "selected-child", + activeConnection.id, + "selected-connection", + ), + revision: 14, + }, input)).toBe(true); + await vi.waitFor(() => expect(signals).toHaveLength(1)); + expect(signals[0]?.connectionId).toBe("selected-connection"); + + const activation = owner.activate({ + revision: 14, + assignment: input.assignment, + activeChildPeerIds: ["selected-child"], + maxMediaEdges: 2, + }); + expect(activation.kind).toBe("promote"); + if (activation.kind === "promote") { + activation.peer.dispose(); } - await expect(racingReplacement).resolves.toBe(false); - expect(promotedFailure).toHaveBeenCalledWith(racingActivation.peer); - racingActivation.peer.dispose(); }); }); @@ -1341,43 +1284,6 @@ describe("ViewerRelay downstream ownership", () => { relay.dispose(); }); - it("fails closed when the committed prepared connection already failed", async () => { - const signals: Array<{ peerId: string; connectionId: string }> = []; - let rejectSignalsFor: string | null = null; - const relay = new ViewerRelay( - { iceServers: [] }, - QUALITY_PROFILES["720p30"], - { - sendSignal: (peerId, payload) => { - signals.push({ peerId, connectionId: payload.connectionId }); - return peerId !== rejectSignalsFor; - }, - }, - ); - relay.setStream(createStream(createTrack("video", "failed-active-video"), null)); - - rejectSignalsFor = "failed-child"; - expect(relay.prepareChild(7, ["failed-child"])).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(1)); - const failedConnectionId = signals[0]!.connectionId; - const failedConnection = FakePeerConnection.latest!; - await vi.waitFor(() => expect(failedConnection.connectionState).toBe("closed")); - const instanceCount = FakePeerConnection.instances.length; - expect(relay.prepareChild(7, ["failed-child"])).toBe(false); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); - - relay.setChildren(["failed-child"]); - relay.activateChildren(7, ["failed-child"]); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(FakePeerConnection.activeCount).toBe(0); - relay.setChildren(["failed-child"]); - relay.activateChildren(7, ["failed-child"]); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(signals).toHaveLength(1); - expect(signals[0]!.connectionId).toBe(failedConnectionId); - relay.dispose(); - }); - it("keeps the prepared connection when its stream replacement succeeds", async () => { const signals: Array<{ peerId: string; connectionId: string }> = []; const relay = new ViewerRelay( @@ -1412,37 +1318,6 @@ describe("ViewerRelay downstream ownership", () => { relay.dispose(); }); - it("retains failed replacement identity across repeated active updates", async () => { - const signals: Array<{ peerId: string; connectionId: string }> = []; - const relay = new ViewerRelay( - { iceServers: [] }, - QUALITY_PROFILES["720p30"], - { - sendSignal: (peerId, payload) => { - signals.push({ peerId, connectionId: payload.connectionId }); - return true; - }, - }, - ); - relay.setStream(createStream(createTrack("video", "initial-video"), null)); - expect(relay.prepareChild(7, ["prepared-child"])).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(1)); - const preparedConnection = FakePeerConnection.latest!; - const preparedConnectionId = signals[0]!.connectionId; - preparedConnection.senders[0]!.failNextReplace = true; - const instanceCount = FakePeerConnection.instances.length; - - relay.setStream(createStream(createTrack("video", "failed-video"), null)); - await vi.waitFor(() => expect(preparedConnection.connectionState).toBe("closed")); - relay.activateChildren(7, ["prepared-child"]); - relay.activateChildren(7, ["prepared-child"]); - expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(FakePeerConnection.activeCount).toBe(0); - expect(signals).toHaveLength(1); - expect(signals[0]!.connectionId).toBe(preparedConnectionId); - relay.dispose(); - }); - it("admits provisional children until endpoint cap three is full", async () => { const relay = new ViewerRelay( { iceServers: [] }, @@ -1470,419 +1345,70 @@ describe("ViewerRelay downstream ownership", () => { relay.dispose(); }); - it("clamps a malicious fourth child to three physical endpoint copies", async () => { - const targets: string[] = []; + it("replaces the exact prepared child with selected TURN in the same slot", async () => { + const signals: Array<{ peerId: string; connectionId: string }> = []; const relay = new ViewerRelay( { iceServers: [] }, - QUALITY_PROFILES["1080p60"], + QUALITY_PROFILES["720p30"], { - sendSignal: (peerId) => { - targets.push(peerId); + sendSignal: (peerId, payload) => { + signals.push({ peerId, connectionId: payload.connectionId }); return true; }, }, ); - const firstVideo = createTrack("video", "first-video"); - const firstAudio = createTrack("audio", "first-audio"); - relay.setChildren(["child-a", "child-b", "child-c", "ignored-fourth"]); - relay.setStream(createStream(firstVideo, firstAudio)); - - await vi.waitFor(() => - expect(targets).toEqual(["child-a", "child-b", "child-c"]), - ); - const [childAConnection, childBConnection, childCConnection] = - FakePeerConnection.instances; - expect(childAConnection).toBeDefined(); - expect(childBConnection).toBeDefined(); - expect(childCConnection).toBeDefined(); - childAConnection!.connectionState = "connected"; - childBConnection!.connectionState = "connected"; - childCConnection!.connectionState = "connected"; - const retiredConnectionId = relay.getSnapshot("child-a")!.connectionId; - - const nextVideo = createTrack("video", "next-video"); - const nextAudio = createTrack("audio", "next-audio"); - relay.setStream(createStream(nextVideo, nextAudio)); - await vi.waitFor(() => { - expect(childAConnection!.senders[0]?.track).toBe(nextVideo); - expect(childAConnection!.senders[1]?.track).toBe(nextAudio); - }); - - relay.setChildren(["child-b", "child-c", "child-d"]); - await vi.waitFor(() => expect(targets).toContain("child-d")); - expect(childAConnection!.connectionState).toBe("closed"); - FakePeerConnection.latest!.connectionState = "connected"; - expect(targets).toEqual(["child-a", "child-b", "child-c", "child-d"]); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); - expect(targets).not.toContain("ignored-fourth"); - - await relay.acceptSignal("stale-child", { - kind: "description", - connectionId: "stale-connection", - description: { type: "answer", sdp: "stale-answer" }, - }, 7); - await relay.recover("stale-child", "stale-connection", true); - await relay.recover("child-b", "wrong-connection", true); - expect(FakePeerConnection.activeCount).toBe(3); - const childBConnectionId = relay.getSnapshot("child-b")!.connectionId; - await relay.recover("child-b", childBConnectionId, true); - await vi.waitFor(() => - expect(FakePeerConnection.latest).not.toBe(childBConnection), - ); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); - - expect( - relay.startSelectedEdgeTurn( - selectedEdgeTurn( - "child-a", - retiredConnectionId, - "selected-connection-overflow", - ), - "selected-parent", - 7, - ), - ).toBe(false); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); - relay.dispose(); - }); - - it("rebuilds a retired child as one relay-only selected edge", async () => { - const sendSignal = vi.fn(() => true); - const relay = new ViewerRelay( - { iceServers: [] }, - QUALITY_PROFILES["720p30"], - { sendSignal }, - ); relay.setChildren(["selected-child"]); relay.setStream(createStream(createTrack("video", "selected-video"), null)); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce()); - const oldConnectionId = relay.getSnapshot()!.connectionId; - FakePeerConnection.latest!.connectionState = "connected"; - relay.setChildren([]); - sendSignal.mockClear(); - const selectedGrant = selectedEdgeTurn( - "selected-child", - oldConnectionId, - "selected-connection-new", - ); - expect( - relay.startSelectedEdgeTurn(selectedGrant, "wrong-parent", 7), - ).toBe(false); - expect( - relay.startSelectedEdgeTurn(selectedGrant, "selected-parent", 6), - ).toBe(false); - expect( - relay.startSelectedEdgeTurn( - selectedGrant, - "selected-parent", - 7, - Date.parse(selectedGrant.expiresAt), - ), - ).toBe(false); - expect( - relay.startSelectedEdgeTurn( - selectedGrant, - "selected-parent", - 7, - ), - ).toBe(true); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce()); - expect(sendSignal).toHaveBeenCalledWith( - "selected-child", - expect.objectContaining({ - kind: "description", - connectionId: "selected-connection-new", - description: expect.objectContaining({ type: "offer" }), - }), - ); - expect(FakePeerConnection.latest?.configurations).toEqual([{ - iceServers: [selectedTurnIceServer], - iceTransportPolicy: "relay", - }]); - const selectedConnection = FakePeerConnection.latest!; - expect( - relay.startSelectedEdgeTurn( - { ...selectedGrant, revision: 8 }, - "selected-parent", - 7, - ), - ).toBe(true); - expect(FakePeerConnection.latest).toBe(selectedConnection); - expect(sendSignal).toHaveBeenCalledOnce(); - relay.acceptActiveRevision(8); - relay.setChildren([]); - expect(selectedConnection.connectionState).not.toBe("closed"); - expect(relay.getSnapshot("selected-child")?.connectionId).toBe( - "selected-connection-new", - ); - expect(FakePeerConnection.activeCount).toBe(1); - expect(FakePeerConnection.peakActiveCount).toBe(1); - const pendingCandidate = { candidate: "candidate-before-answer" }; - await expect( - relay.acceptSignal("selected-child", { - kind: "candidate", - connectionId: "selected-connection-new", - candidate: pendingCandidate, - }, 8), - ).resolves.toBe(true); - expect(selectedConnection.addedIceCandidates).toEqual([]); - await expect( - relay.acceptSignal("selected-child", { - kind: "description", - connectionId: "selected-connection-new", - description: { type: "answer", sdp: "selected-answer" }, - }, 8), - ).resolves.toBe(true); - expect(selectedConnection.remoteDescription?.type).toBe("answer"); - expect(selectedConnection.addedIceCandidates).toEqual([pendingCandidate]); - await expect( - relay.acceptSignal("selected-child", { - kind: "candidate", - connectionId: "selected-connection-new", - candidate: null, - }, 8), - ).resolves.toBe(true); - expect(selectedConnection.addedIceCandidates).toEqual([ - pendingCandidate, - null, - ]); - await expect( - relay.acceptSignal("selected-child", { - kind: "candidate", - connectionId: "selected-connection-new", - candidate: null, - }, 7), - ).resolves.toBe(false); - await expect( - relay.acceptSignal("selected-child", { - kind: "candidate", - connectionId: oldConnectionId, - candidate: null, - }, 8), - ).resolves.toBe(false); - expect( - relay.startSelectedEdgeTurn( - { - ...selectedEdgeTurn( - "selected-child", - "selected-connection-new", - "selected-connection-replacement", - ), - revision: 9, - }, - "selected-parent", - 9, - ), - ).toBe(true); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledTimes(2)); - expect(selectedConnection.connectionState).toBe("closed"); - const replacementConnection = FakePeerConnection.latest!; - expect(replacementConnection.connectionState).not.toBe("closed"); - expect(FakePeerConnection.activeCount).toBe(1); - expect(FakePeerConnection.peakActiveCount).toBe(1); - relay.acceptActiveRevision(9); - expect(replacementConnection.connectionState).toBe("closed"); - await expect( - relay.acceptSignal("selected-child", { - kind: "candidate", - connectionId: "selected-connection-replacement", - candidate: null, - }, 9), - ).resolves.toBe(false); - expect(FakePeerConnection.activeCount).toBe(0); - expect(FakePeerConnection.peakActiveCount).toBe(1); - relay.dispose(); - }); - - it("keeps concurrent selected children isolated across carry and failure", async () => { - const sendSignal = vi.fn(() => true); - const onSelectedEdgeFailed = vi.fn(); - const relay = new ViewerRelay( - { iceServers: [] }, - QUALITY_PROFILES["720p30"], - { sendSignal, onSelectedEdgeFailed }, - ); - relay.setChildren(["selected-a", "selected-b"]); - relay.setStream(createStream(createTrack("video", "selected-video"), null)); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledTimes(2)); - const oldConnectionA = relay.getSnapshot("selected-a")!.connectionId; - const oldConnectionB = relay.getSnapshot("selected-b")!.connectionId; - relay.setChildren([]); - expect(FakePeerConnection.activeCount).toBe(0); - - sendSignal.mockClear(); - const grantA = selectedEdgeTurn( - "selected-a", - oldConnectionA, - "selected-connection-a", - ); - const grantB = selectedEdgeTurn( - "selected-b", - oldConnectionB, - "selected-connection-b", - ); - expect(relay.startSelectedEdgeTurn(grantA, "selected-parent", 7)).toBe(true); - expect(relay.startSelectedEdgeTurn(grantB, "selected-parent", 7)).toBe(true); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledTimes(2)); - const [selectedConnectionA, selectedConnectionB] = - FakePeerConnection.instances.slice(-2); - expect(selectedConnectionA).toBeDefined(); - expect(selectedConnectionB).toBeDefined(); - expect(FakePeerConnection.activeCount).toBe(2); + await vi.waitFor(() => expect(signals).toHaveLength(1)); + const activeConnection = FakePeerConnection.latest!; + const activeConnectionId = signals[0]!.connectionId; + activeConnection.connectionState = "connected"; + expect(relay.prepareChild(7, ["selected-child"])).toBe(true); + expect(FakePeerConnection.latest).toBe(activeConnection); expect( - relay.startSelectedEdgeTurn( - { ...grantA, revision: 8 }, - "selected-parent", - 7, - ), - ).toBe(true); - expect( - relay.startSelectedEdgeTurn( - { ...grantB, revision: 8 }, - "selected-parent", - 7, - ), - ).toBe(true); - relay.acceptActiveRevision(8); - relay.setChildren([]); - expect(selectedConnectionA!.connectionState).not.toBe("closed"); - expect(selectedConnectionB!.connectionState).not.toBe("closed"); - expect(FakePeerConnection.activeCount).toBe(2); - - await expect( - relay.acceptSignal( - "selected-a", - { - kind: "description", - connectionId: "selected-connection-a", - description: { type: "answer", sdp: "selected-answer-a" }, - }, - 8, - ), - ).resolves.toBe(true); - await expect( - relay.acceptSignal( - "selected-b", - { - kind: "description", - connectionId: "selected-connection-b", - description: { type: "answer", sdp: "selected-answer-b" }, - }, - 8, - ), - ).resolves.toBe(true); - expect(selectedConnectionA!.remoteDescription?.sdp).toBe( - "selected-answer-a", - ); - expect(selectedConnectionB!.remoteDescription?.sdp).toBe( - "selected-answer-b", - ); - - FakePeerConnection.offersFailing = 1; - expect( - relay.startSelectedEdgeTurn( - { - ...selectedEdgeTurn( - "selected-a", - "selected-connection-a", - "selected-connection-a-failed", - ), - revision: 8, - }, - "selected-parent", - 8, - ), - ).toBe(true); - await vi.waitFor(() => - expect(onSelectedEdgeFailed).toHaveBeenCalledWith( - "selected-a", - "selected-connection-a-failed", - 8, - ), - ); - expect(selectedConnectionA!.connectionState).toBe("closed"); - expect(selectedConnectionB!.connectionState).not.toBe("closed"); - expect(relay.getSnapshot("selected-a")).toBeNull(); - expect(relay.getSnapshot("selected-b")?.connectionId).toBe( - "selected-connection-b", - ); - expect(FakePeerConnection.activeCount).toBe(1); - await expect( - relay.acceptSignal( - "selected-a", - { - kind: "candidate", - connectionId: "selected-connection-a-failed", - candidate: null, - }, - 8, - ), - ).resolves.toBe(false); - await expect( - relay.acceptSignal( - "selected-b", - { - kind: "candidate", - connectionId: "selected-connection-b", - candidate: null, - }, - 8, - ), - ).resolves.toBe(true); - - expect( - relay.startSelectedEdgeTurn( - { ...grantB, revision: 9 }, - "selected-parent", - 8, - ), - ).toBe(true); - relay.acceptActiveRevision(9); - expect(selectedConnectionB!.connectionState).not.toBe("closed"); - relay.clearSelectedEdgeTurn(); - expect(selectedConnectionB!.connectionState).toBe("closed"); - expect(FakePeerConnection.activeCount).toBe(0); - relay.dispose(); - }); - - it("reports a selected child whose relay-only offer fails", async () => { - const onSelectedEdgeFailed = vi.fn(); - const relay = new ViewerRelay( - { iceServers: [] }, - QUALITY_PROFILES["720p30"], - { sendSignal: () => true, onSelectedEdgeFailed }, - ); - relay.setChildren(["selected-child"]); - relay.setStream(createStream(createTrack("video", "selected-video"), null)); - await vi.waitFor(() => expect(relay.getSnapshot()).not.toBeNull()); - const oldConnectionId = relay.getSnapshot()!.connectionId; - relay.setChildren([]); - FakePeerConnection.offersFailing = 1; - - expect( - relay.startSelectedEdgeTurn( + relay.prepareSelectedEdgeTurn( selectedEdgeTurn( "selected-child", - oldConnectionId, - "selected-failed-connection", + activeConnectionId, + "selected-connection", ), "selected-parent", 7, ), ).toBe(true); - await vi.waitFor(() => - expect(onSelectedEdgeFailed).toHaveBeenCalledWith( + await vi.waitFor(() => expect(signals).toHaveLength(2)); + const selectedConnection = FakePeerConnection.latest!; + expect(activeConnection.connectionState).toBe("connected"); + expect(selectedConnection.configurations).toEqual([ + { + iceServers: [selectedTurnIceServer], + iceTransportPolicy: "relay", + }, + ]); + expect(signals[1]).toEqual({ + peerId: "selected-child", + connectionId: "selected-connection", + }); + + await expect( + relay.acceptSignal( "selected-child", - "selected-failed-connection", + { + kind: "description", + connectionId: "selected-connection", + description: { type: "answer", sdp: "selected-answer" }, + }, 7, ), + ).resolves.toBe(true); + selectedConnection.connectionState = "connected"; + relay.activateChildren(7, ["selected-child"]); + expect(activeConnection.connectionState).toBe("closed"); + expect(relay.getSnapshot("selected-child")?.connectionId).toBe( + "selected-connection", ); - expect(relay.getSnapshot("selected-child")).toBeNull(); - expect(FakePeerConnection.latest?.connectionState).toBe("closed"); + expect(FakePeerConnection.activeCount).toBe(1); relay.dispose(); }); diff --git a/tests/route-transition.test.ts b/tests/route-transition.test.ts index c1d2da1..057013f 100644 --- a/tests/route-transition.test.ts +++ b/tests/route-transition.test.ts @@ -4,25 +4,15 @@ import type { ClientMessage, ParticipantRouteAssignment, } from "../src/shared/protocol.ts"; +import { HostSfuRoute } from "../src/client/media/host-sfu-route.ts"; +import { QUALITY_PROFILES } from "../src/client/media/quality.ts"; import { + exactPeerSignalOwner, MediaRouteTransition, reportActivePeerRouteFailure, } from "../src/client/media/route-transition.ts"; -import { HostSfuRoute } from "../src/client/media/host-sfu-route.ts"; -import { - acceptHostSelectedChildrenRevision, - carryHostSelectedChild, - hostSelectedChildForConnection, - plannedHostProvisionalChild, - resolveHostPreparedChildActivation, - type ActiveSelectedHostChild, -} from "../src/client/media/host-provisional-child.ts"; import { ViewerSfuRoute } from "../src/client/media/viewer-sfu-route.ts"; -import { QUALITY_PROFILES } from "../src/client/media/quality.ts"; -import { - EMPTY_METRICS, - type ConnectionMetrics, -} from "../src/client/types.ts"; +import type { ConnectionMetrics } from "../src/client/types.ts"; const peerAssignment = ( parentPeerId: string, @@ -33,7 +23,9 @@ const peerAssignment = ( sfuPublicationGeneration: null, }); -const sfuAssignment = (childPeerIds: string[] = []): ParticipantRouteAssignment => ({ +const viewerSfuAssignment = ( + childPeerIds: string[] = [], +): ParticipantRouteAssignment => ({ upstream: { kind: "sfu" }, childPeerIds, sfuPublicationGeneration: null, @@ -57,31 +49,9 @@ function sfuConfig(revision: number) { }; } -function hostSfuIngressGrant( - newConnectionId = "selected-connection-new", - revision = 1, - publicationGeneration = "generation-a", -) { - return { - type: "selected-edge-turn" as const, - edgeKind: "host-sfu-ingress" as const, - revision, - hostPeerId: "host_12345678", - publicationGeneration, - oldConnectionId: publicationGeneration, - newConnectionId, - expiresAt: "2099-01-01T00:00:00.000Z", - iceServer: { - urls: ["turn:turn.example.test:3478?transport=udp"] as [string], - username: "1787230000:opaque_identity_12345678", - credential: "short-lived-credential", - }, - }; -} - function createFakePublisher(log: string[], label: string) { return { - connect: vi.fn(async () => { + connect: vi.fn(async (_config: unknown) => { log.push(`${label}:connect`); return true; }), @@ -101,210 +71,11 @@ function createFakePublisher(log: string[], label: string) { }; } -describe("Host provisional child ownership", () => { - it("keeps selected Host children isolated by peer and connection", () => { - const selected = new Map([ - [ - "selected-a", - { - peerId: "selected-a", - connectionId: "connection-a", - currentRouteRevision: 7, - pendingCarryRevision: null, - }, - ], - [ - "selected-b", - { - peerId: "selected-b", - connectionId: "connection-b", - currentRouteRevision: 7, - pendingCarryRevision: null, - }, - ], - ]); +type SubscriberEvents = Parameters< + NonNullable[0]["createSubscriber"]> +>[0]; - expect( - hostSelectedChildForConnection( - selected, - "selected-a", - "wrong-connection", - ), - ).toBeUndefined(); - expect( - carryHostSelectedChild(selected, "selected-a", "connection-a", 8), - ).toBe(true); - expect( - carryHostSelectedChild(selected, "selected-b", "connection-b", 8), - ).toBe(true); - expect(acceptHostSelectedChildrenRevision(selected, 8)).toEqual([]); - expect(selected.size).toBe(2); - - const failed = hostSelectedChildForConnection( - selected, - "selected-a", - "connection-a", - ); - expect(failed).toBeDefined(); - selected.delete(failed!.peerId); - expect(selected.has("selected-a")).toBe(false); - expect(selected.has("selected-b")).toBe(true); - - selected.set("selected-a", { - peerId: "selected-a", - connectionId: "connection-a-2", - currentRouteRevision: 8, - pendingCarryRevision: null, - }); - expect( - carryHostSelectedChild(selected, "selected-b", "connection-b", 9), - ).toBe(true); - expect(acceptHostSelectedChildrenRevision(selected, 9)).toEqual([ - "selected-a", - ]); - expect([...selected.keys()]).toEqual(["selected-b"]); - }); - - it("admits only one strict child within the physical Host edge budget", () => { - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment(null, ["active-child", "probe-child"]), - activeChildPeerIds: ["active-child"], - selectedPeerIds: [], - maxMediaEdges: 2, - }), - ).toBe("probe-child"); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment("publication", ["probe-child"]), - activeChildPeerIds: [], - selectedPeerIds: [], - maxMediaEdges: 2, - }), - ).toBe("probe-child"); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment(null, ["probe-child"]), - activeChildPeerIds: [], - selectedPeerIds: ["selected-overlay"], - maxMediaEdges: 2, - }), - ).toBe("probe-child"); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment("publication", [ - "active-child", - "probe-child", - ]), - activeChildPeerIds: ["active-child"], - selectedPeerIds: [], - maxMediaEdges: 2, - }), - ).toBeNull(); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment(null, ["active-child", "probe-child"]), - activeChildPeerIds: ["active-child"], - selectedPeerIds: ["selected-overlay"], - maxMediaEdges: 2, - }), - ).toBeNull(); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment(null, ["probe-child"]), - activeChildPeerIds: [], - selectedPeerIds: ["selected-a", "selected-b"], - maxMediaEdges: 3, - }), - ).toBe("probe-child"); - expect( - plannedHostProvisionalChild({ - revision: 7, - assignment: hostAssignment("publication", ["probe-child"]), - activeChildPeerIds: [], - selectedPeerIds: ["selected-a", "selected-b"], - maxMediaEdges: 3, - }), - ).toBeNull(); - }); - - it("promotes the exact identity and preserves a failed connection tombstone", () => { - const input = { - revision: 7, - assignment: hostAssignment(null, ["active-child", "probe-child"]), - activeChildPeerIds: ["active-child"], - selectedPeerIds: [], - maxMediaEdges: 2, - }; - expect( - resolveHostPreparedChildActivation({ - ...input, - prepared: { - revision: 7, - peerId: "probe-child", - failedConnectionId: null, - }, - }), - ).toEqual({ kind: "promote", peerId: "probe-child" }); - expect( - resolveHostPreparedChildActivation({ - ...input, - prepared: { - revision: 7, - peerId: "probe-child", - failedConnectionId: "failed-probe-connection", - }, - }), - ).toEqual({ - kind: "failed", - peerId: "probe-child", - connectionId: "failed-probe-connection", - }); - expect( - resolveHostPreparedChildActivation({ ...input, prepared: null }), - ).toEqual({ kind: "ordinary" }); - expect( - resolveHostPreparedChildActivation({ - ...input, - prepared: { - revision: 6, - peerId: "probe-child", - failedConnectionId: null, - }, - }), - ).toEqual({ kind: "ordinary" }); - expect( - resolveHostPreparedChildActivation({ - ...input, - prepared: { - revision: 7, - peerId: "other-child", - failedConnectionId: null, - }, - }), - ).toEqual({ kind: "ordinary" }); - }); -}); - -interface FakeSubscriberEvents { - onStream: (stream: MediaStream | null) => void; - onVideoAvailability?: (available: boolean) => void; - onStats?: (metrics: ConnectionMetrics) => void; - onState?: (state: "connected" | "reconnecting") => void; - onDisconnected: () => void; -} - -function createFakeSubscriber( - events: FakeSubscriberEvents, - log: string[], - label: string, -) { +function createFakeSubscriber(events: SubscriberEvents, log: string[], label: string) { return { events, connect: vi.fn(async () => { @@ -325,290 +96,137 @@ function createFakeSubscriber( }; } -async function activateViewerSfuRoute( - events: ConstructorParameters[0], - log: string[] = [], -) { - let subscriber!: ReturnType; - const route = new ViewerSfuRoute({ - ...events, - createSubscriber: (subscriberEvents) => { - subscriber = createFakeSubscriber(subscriberEvents, log, "subscriber"); - return subscriber; - }, - }); - route.accept({ revision: 1, phase: "prepare", assignment: sfuAssignment() }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment: sfuAssignment() }); - await vi.waitFor(() => expect(subscriber.activate).toHaveBeenCalledOnce()); - subscriber.events.onStream({} as MediaStream); - return { route, subscriber }; -} - -describe("MediaRouteTransition", () => { - it("keeps current media through prepare and until an active SFU video track", () => { +describe("minimal route transition contracts", () => { + it("keeps old media through prepare and commits only exact active authority", () => { const route = new MediaRouteTransition(); - const direct = peerAssignment("host_12345678", ["child_a_12345678"]); - const sfu = sfuAssignment(["child_a_12345678"]); + const oldAssignment = peerAssignment("old-parent"); + const nextAssignment = peerAssignment("next-parent"); - expect(route.accept({ revision: 1, phase: "active", assignment: direct })).toBe( - "accepted", - ); - const directToken = route.token()!; - expect(route.markMediaActive(directToken)).toBe(true); + route.accept({ revision: 1, phase: "active", assignment: oldAssignment }); + const oldToken = route.token()!; + expect(route.markMediaActive(oldToken)).toBe(true); - expect(route.accept({ revision: 2, phase: "prepare", assignment: sfu })).toBe( - "accepted", - ); - expect(route.getActiveAssignment()).toEqual(direct); - expect(route.getMediaAssignment()).toEqual(direct); - - expect(route.accept({ revision: 2, phase: "active", assignment: sfu })).toBe( - "accepted", - ); - const sfuToken = route.token()!; - expect(route.getActiveAssignment()).toEqual(sfu); - expect(route.getMediaAssignment()).toEqual(direct); - - expect(route.markMediaActive(sfuToken)).toBe(true); - expect(route.getMediaAssignment()).toEqual(sfu); - }); - - it("rejects stale config and async work after a rollback revision", () => { - const route = new MediaRouteTransition(); - const sfu = sfuAssignment(); - const direct = peerAssignment("host_12345678"); - - route.accept({ revision: 4, phase: "prepare", assignment: sfu }); - const staleToken = route.token()!; - expect(route.acceptsConfig(4)).toBe(true); - - route.accept({ revision: 5, phase: "active", assignment: direct }); + route.accept({ revision: 2, phase: "prepare", assignment: nextAssignment }); + expect(route.getMediaAssignment()).toEqual(oldAssignment); + expect(route.markMediaActive(route.token()!)).toBe(false); + route.accept({ revision: 2, phase: "active", assignment: nextAssignment }); + expect(route.markMediaActive(route.token()!)).toBe(true); + expect(route.getMediaAssignment()).toEqual(nextAssignment); expect( - route.accept({ revision: 4, phase: "active", assignment: sfu }), + route.accept({ revision: 1, phase: "active", assignment: oldAssignment }), ).toBe("stale"); - expect(route.acceptsConfig(4)).toBe(false); - expect(route.owns(staleToken)).toBe(false); - expect(route.markMediaActive(staleToken)).toBe(false); }); - it("is idempotent for duplicates and rejects conflicting same revisions", () => { - const route = new MediaRouteTransition(); - const direct = peerAssignment("host_12345678"); + it("keeps duplicate peer prepare idempotent and releases it before takeover", async () => { + const prepared: Array<{ parentPeerId: string | null; revision?: number }> = []; + const activatePeer = vi.fn(() => true); + const route = new ViewerSfuRoute({ + activatePeer, + preparePeer: (assignment, revision) => + prepared.push({ + parentPeerId: + assignment?.upstream.kind === "peer" + ? assignment.upstream.peerId + : null, + ...(revision === undefined ? {} : { revision }), + }), + reconcileSfuChildren: () => undefined, + onSfuStream: () => undefined, + send: () => true, + }); + const oldAssignment = peerAssignment("old-parent"); + const nextAssignment = peerAssignment("next-parent"); + + route.accept({ revision: 1, phase: "active", assignment: oldAssignment }); + await vi.waitFor(() => expect(activatePeer).toHaveBeenCalled()); + prepared.length = 0; - expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe( - "accepted", - ); - const token = route.token(); - expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe( - "duplicate", - ); - expect(route.token()).toEqual(token); expect( route.accept({ - revision: 7, + revision: 2, phase: "prepare", - assignment: peerAssignment("other_12345678"), + assignment: nextAssignment, }), - ).toBe("stale"); - }); - - it("returns signaling failure so ViewerPeer can retry its route report", () => { - const route = new MediaRouteTransition(); - const assignment = peerAssignment("host_12345678"); - route.accept({ revision: 9, phase: "active", assignment }); - const send = vi.fn(() => false); + ).toBe("accepted"); + expect(prepared).toEqual([ + { parentPeerId: "next-parent", revision: 2 }, + ]); expect( - reportActivePeerRouteFailure( - route, - "host_12345678", - "connection_12345678", - send, - ), - ).toBe(false); - expect(send).toHaveBeenCalledWith({ - type: "route-failed", - revision: 9, - phase: "active", - connectionId: "connection_12345678", - }); - }); -}); - -describe("HostSfuRoute", () => { - it("exposes only current active publication stats and clears retired evidence", async () => { - const updates: Array< - Parameters[0]["onPublisherUpdate"]>>[0] - > = []; - const statsCallbacks: Array<(metrics: ConnectionMetrics | null) => void> = []; - const publishers: ReturnType[] = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - onPublisherUpdate: (snapshot) => updates.push(snapshot), - createPublisher: (_onDisconnected, onStats) => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - statsCallbacks.push(onStats); - return publisher; - }, - }); - const metrics = { - ...EMPTY_METRICS, - trackIdentifier: "screen-a", - framesPerSecond: 60, - }; - const generationA = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - await route.acceptAndWait({ - revision: 1, - phase: "active", - assignment: generationA, - }); - - statsCallbacks[0]?.(metrics); - expect(updates.at(-1)).toMatchObject({ - metrics: { trackIdentifier: "screen-a", framesPerSecond: 60 }, - }); - - const generationB = hostAssignment("generation-b"); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - await route.acceptAndWait({ - revision: 2, - phase: "active", - assignment: generationB, - }); - expect(updates.at(-1)).toBeNull(); - - statsCallbacks[0]?.({ ...metrics, framesPerSecond: 1 }); - expect(updates.at(-1)).toBeNull(); - statsCallbacks[1]?.({ - ...metrics, - trackIdentifier: "screen-b", - framesPerSecond: 30, - }); - expect(updates.at(-1)).toMatchObject({ - metrics: { trackIdentifier: "screen-b", framesPerSecond: 30 }, - }); - - await route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: hostAssignment(null), - }); - expect(updates.at(-1)).toBeNull(); - }); - - it("settles authoritative activation before exposing its quality warning", async () => { - const publisher = { - ...createFakePublisher([], "publisher"), - getQualityWarning: vi.fn(() => "SFU sender parameters were rewritten"), - }; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - createPublisher: () => publisher, - }); - const assignment = hostAssignment("generation-a"); - - route.accept({ revision: 1, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(1)); - await expect( - route.resyncAuthoritative({ revision: 1, phase: "active", assignment }), - ).resolves.toBe("accepted"); - - expect(publisher.activate).toHaveBeenCalledOnce(); - expect(route.getQualityWarning()).toBe( - "SFU sender parameters were rewritten", - ); - }); - - it("reports a publisher prepare failure without requesting refresh", async () => { - const messages: ClientMessage[] = []; - const publisher = createFakePublisher([], "publisher"); - publisher.connect.mockResolvedValue(false); - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: () => publisher, - }); + route.accept({ + revision: 2, + phase: "prepare", + assignment: nextAssignment, + }), + ).toBe("duplicate"); + expect(prepared).toEqual([ + { parentPeerId: "next-parent", revision: 2 }, + ]); route.accept({ - revision: 1, + revision: 3, phase: "prepare", - assignment: hostAssignment("generation-a"), + assignment: peerAssignment("third-parent"), }); - await route.acceptConfig(sfuConfig(1)); + expect(prepared.slice(-2)).toEqual([ + { parentPeerId: null }, + { parentPeerId: "third-parent", revision: 3 }, + ]); - expect(messages).toEqual([ + route.accept({ + revision: 4, + phase: "prepare", + assignment: viewerSfuAssignment(), + }); + expect(prepared.at(-1)).toEqual({ parentPeerId: null }); + }); + + it("routes same-parent overlap signals only by exact connection identity", () => { + expect(exactPeerSignalOwner("pending", "pending", "active")).toBe( + "pending", + ); + expect(exactPeerSignalOwner("active", "pending", "active")).toBe( + "active", + ); + expect(exactPeerSignalOwner("unknown", "pending", "active")).toBeNull(); + }); + + it("reports only the exact active peer failure", () => { + const route = new MediaRouteTransition(); + const sent: ClientMessage[] = []; + route.accept({ + revision: 4, + phase: "active", + assignment: peerAssignment("parent"), + }); + + expect( + reportActivePeerRouteFailure(route, "other", "connection", (message) => { + sent.push(message); + return true; + }), + ).toBe(true); + expect(sent).toEqual([]); + + reportActivePeerRouteFailure(route, "parent", "connection", (message) => { + sent.push(message); + return true; + }); + expect(sent).toEqual([ { type: "route-failed", - revision: 1, - phase: "prepare", - connectionId: null, + revision: 4, + phase: "active", + connectionId: "connection", }, ]); }); - it("reports the pending peer revision when the active publisher fails", async () => { - const messages: ClientMessage[] = []; - const publisher = createFakePublisher([], "publisher"); - let disconnectActive!: () => void; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: (onDisconnected) => { - disconnectActive = onDisconnected; - return publisher; - }, - }); - const active = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: active }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment: active }); - await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce()); - - messages.length = 0; - route.accept({ - revision: 2, - phase: "prepare", - assignment: hostAssignment(null, ["viewer-root"]), - }); - disconnectActive(); - - expect(messages).toEqual([{ - type: "route-failed", - revision: 2, - phase: "prepare", - connectionId: null, - }]); - }); - - it("accepts a forward-revision selected ingress before its config arrives", async () => { + it("publishes SFU media during prepare and promotes before retiring old media", async () => { + const log: string[] = []; const messages: ClientMessage[] = []; const publishers: ReturnType[] = []; - const failures: Array<() => void> = []; const route = new HostSfuRoute({ getStream: () => ({}) as MediaStream, getProfile: () => QUALITY_PROFILES["720p30"], @@ -617,161 +235,87 @@ describe("HostSfuRoute", () => { messages.push(message); return true; }, - createPublisher: (onDisconnected) => { + createPublisher: () => { const publisher = createFakePublisher( - [], + log, `publisher-${publishers.length + 1}`, ); publishers.push(publisher); - failures.push(onDisconnected); return publisher; }, }); - const active = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: active }); - await route.acceptConfig(sfuConfig(1)); + await route.acceptAndWait({ revision: 1, phase: "active", - assignment: active, + assignment: hostAssignment("publication-a"), }); + await route.acceptConfig(sfuConfig(1)); + expect(publishers[0]?.activate).toHaveBeenCalledOnce(); - messages.length = 0; route.accept({ revision: 2, phase: "prepare", - assignment: hostAssignment("generation-a", ["viewer-root"]), - }); - failures[0](); - expect(messages).toContainEqual({ - type: "route-failed", - revision: 2, - phase: "prepare", - connectionId: null, + assignment: hostAssignment("publication-b"), }); + await route.acceptConfig(sfuConfig(2)); + expect(publishers[1]?.activate).toHaveBeenCalledOnce(); + expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); + expect(messages.filter((message) => message.type === "route-ready")).toEqual([]); await route.acceptAndWait({ - revision: 3, + revision: 2, phase: "active", - assignment: active, + assignment: hostAssignment("publication-b"), }); - expect(messages).toContainEqual({ type: "refresh-sfu", revision: 3 }); - expect( - route.startSelectedEdgeTurn( - hostSfuIngressGrant("selected-forward-connection", 3), - ), - ).toBe(true); - await route.acceptConfig(sfuConfig(3)); - - await vi.waitFor(() => expect(publishers).toHaveLength(2)); - expect(publishers[1]?.connect).toHaveBeenCalledWith({ - url: "wss://sfu.example.test", - token: "token-3", - rtcConfig: { - iceServers: [hostSfuIngressGrant().iceServer], - iceTransportPolicy: "relay", - }, - }); - await vi.waitFor(() => expect(messages).toContainEqual({ - type: "route-ready", - revision: 3, - phase: "active", - })); + expect(publishers[0]?.deactivate).toHaveBeenCalledOnce(); + expect(publishers[0]?.disconnect).toHaveBeenCalledOnce(); + expect(publishers[1]?.disconnect).not.toHaveBeenCalled(); }); - it("reports the selected ingress connection when it fails during peer prepare", async () => { - const messages: ClientMessage[] = []; + it("replaces one pending Host publication with selected TURN transport", async () => { const publishers: ReturnType[] = []; - const failures: Array<() => void> = []; const route = new HostSfuRoute({ getStream: () => ({}) as MediaStream, getProfile: () => QUALITY_PROFILES["720p30"], reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: (onDisconnected) => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - failures.push(onDisconnected); - return publisher; - }, - }); - const active = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: active }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment: active }); - await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); - failures[0](); - messages.length = 0; - - expect(route.startSelectedEdgeTurn(hostSfuIngressGrant())).toBe(true); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-ready", - revision: 1, - phase: "active", - }), - ); - messages.length = 0; - route.accept({ - revision: 2, - phase: "prepare", - assignment: hostAssignment(null, ["viewer-root"]), - }); - failures[1](); - - expect(messages).toEqual([{ - type: "route-failed", - revision: 2, - phase: "prepare", - connectionId: "selected-connection-new", - }]); - }); - - it("applies a host-SFU selected grant only to a relay-only publisher", async () => { - const messages: ClientMessage[] = []; - const publishers: ReturnType[] = []; - const failures: Array<() => void> = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: (onDisconnected) => { + send: () => true, + createPublisher: () => { const publisher = createFakePublisher([], `publisher-${publishers.length + 1}`); publishers.push(publisher); - failures.push(onDisconnected); - if (publishers.length === 1) { - publisher.connect.mockResolvedValue(false); - } return publisher; }, }); - const assignment = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(1)); - expect(messages).toContainEqual({ - type: "route-failed", - revision: 1, - phase: "prepare", - connectionId: null, - }); + route.accept({ + revision: 2, + phase: "prepare", + assignment: hostAssignment("publication-b"), + }); + await route.acceptConfig(sfuConfig(2)); expect( - route.startSelectedEdgeTurn(hostSfuIngressGrant()), + route.startSelectedEdgeTurn({ + type: "selected-edge-turn", + edgeKind: "host-sfu-ingress", + revision: 2, + hostPeerId: "host_12345678", + publicationGeneration: "publication-b", + oldConnectionId: "publication-b", + newConnectionId: "selected-connection", + expiresAt: "2099-01-01T00:00:00.000Z", + iceServer: { + urls: ["turn:turn.example.test:3478?transport=udp"], + username: "1787230000:opaque_identity_12345678", + credential: "short-lived-credential", + }, + }), ).toBe(true); + await vi.waitFor(() => expect(publishers).toHaveLength(2)); - expect(publishers[1].connect).toHaveBeenCalledWith({ + expect(publishers[0]?.disconnect).toHaveBeenCalled(); + expect(publishers[1]?.connect).toHaveBeenCalledWith({ url: "wss://sfu.example.test", - token: "token-1", + token: "token-2", rtcConfig: { iceServers: [ { @@ -783,788 +327,21 @@ describe("HostSfuRoute", () => { iceTransportPolicy: "relay", }, }); - expect(failures).toHaveLength(2); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 1, - phase: "prepare", - }); }); - it("keeps the old publisher until a selected replacement commits", async () => { - const messages: ClientMessage[] = []; - const publishers: ReturnType[] = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: () => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - if (publishers.length === 2) { - publisher.connect.mockResolvedValue(false); - } - return publisher; - }, - }); - const generationA = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - await route.acceptAndWait({ - revision: 1, - phase: "active", - assignment: generationA, - }); - expect( - route.startSelectedEdgeTurn( - hostSfuIngressGrant("same-generation", 1, "generation-a"), - ), - ).toBe(false); - - const generationB = hostAssignment("generation-b"); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - const selectedB = hostSfuIngressGrant( - "selected-generation-b", - 2, - "generation-b", - ); - expect(route.startSelectedEdgeTurn(selectedB)).toBe(true); - await vi.waitFor(() => expect(publishers).toHaveLength(3)); - expect(publishers[2]?.connect).toHaveBeenCalledWith({ - url: "wss://sfu.example.test", - token: "token-2", - rtcConfig: { - iceServers: [selectedB.iceServer], - iceTransportPolicy: "relay", - }, - }); - expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); - expect(publishers[0]?.disconnect).not.toHaveBeenCalled(); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 2, - phase: "prepare", - }); - - await route.acceptAndWait({ - revision: 2, - phase: "active", - assignment: generationB, - }); - expect(publishers[0]?.deactivate).toHaveBeenCalledOnce(); - expect(publishers[0]?.disconnect).toHaveBeenCalledOnce(); - expect(publishers[2]?.activate).toHaveBeenCalledOnce(); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - }); - - it("keeps the old publisher through rollback and selected candidate failure", async () => { - const messages: ClientMessage[] = []; - const publishers: ReturnType[] = []; - const failures: Array<() => void> = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: (onDisconnected) => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - failures.push(onDisconnected); - if (publishers.length === 2 || publishers.length === 4) { - publisher.connect.mockResolvedValue(false); - } - return publisher; - }, - }); - const generationA = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - await route.acceptAndWait({ - revision: 1, - phase: "active", - assignment: generationA, - }); - - const generationB = hostAssignment("generation-b"); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - expect( - route.startSelectedEdgeTurn( - hostSfuIngressGrant("selected-generation-b", 2, "generation-b"), - ), - ).toBe(true); - await vi.waitFor(() => expect(publishers).toHaveLength(3)); - expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); - expect(publishers[0]?.disconnect).not.toHaveBeenCalled(); - - await route.acceptAndWait({ - revision: 3, - phase: "active", - assignment: generationA, - }); - expect(publishers[2]?.disconnect).toHaveBeenCalledOnce(); - expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); - expect(publishers[0]?.disconnect).not.toHaveBeenCalled(); - - route.accept({ - revision: 4, - phase: "prepare", - assignment: hostAssignment("generation-c"), - }); - await route.acceptConfig(sfuConfig(4)); - expect( - route.startSelectedEdgeTurn( - hostSfuIngressGrant("selected-generation-c", 4, "generation-c"), - ), - ).toBe(true); - await vi.waitFor(() => expect(publishers).toHaveLength(5)); - messages.length = 0; - failures[4]?.(); - expect(messages).toEqual([{ - type: "route-failed", - revision: 4, - phase: "prepare", - connectionId: "selected-generation-c", - }]); - await expect( - route.updateProfile(QUALITY_PROFILES["1080p30"]), - ).resolves.toBe(true); - expect(publishers[0]?.updateProfile).toHaveBeenCalledOnce(); - expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); - expect(publishers[0]?.disconnect).not.toHaveBeenCalled(); - }); - - it("identifies a failed selected ingress retry by its new connection", async () => { - const messages: ClientMessage[] = []; - const publishers: ReturnType[] = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: () => { - const publisher = createFakePublisher([], `publisher-${publishers.length + 1}`); - publisher.connect.mockResolvedValue(false); - publishers.push(publisher); - return publisher; - }, - }); - - route.accept({ - revision: 1, - phase: "prepare", - assignment: hostAssignment("generation-a"), - }); - await route.acceptConfig(sfuConfig(1)); - expect(route.startSelectedEdgeTurn(hostSfuIngressGrant())).toBe(true); - - await vi.waitFor(() => expect(publishers).toHaveLength(2)); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-failed", - revision: 1, - phase: "prepare", - connectionId: "selected-connection-new", - }), - ); - }); - - it("surfaces a bounded publisher stage after active fallback fails", async () => { - const publisher = { - ...createFakePublisher([], "publisher"), - getFailureStage: vi.fn(() => "video-publish" as const), - }; - publisher.activate.mockResolvedValue(false); - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - createPublisher: () => publisher, - }); - const assignment = hostAssignment("generation-a"); - - route.accept({ revision: 1, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(1)); - await route.acceptAndWait({ revision: 1, phase: "active", assignment }); - - expect(route.getQualityWarning()).toBe( - "SFU 视频发布失败,已启动自动恢复", - ); - - route.accept({ - revision: 2, - phase: "active", - assignment: hostAssignment(null), - }); - expect(route.getQualityWarning()).toBeNull(); - }); - - it("clears a prior failure when authoritative state replaces the revision", async () => { - const publisher = { - ...createFakePublisher([], "publisher"), - getFailureStage: vi.fn(() => "transport" as const), - }; - publisher.activate.mockResolvedValue(false); - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - createPublisher: () => publisher, - }); - const failedAssignment = hostAssignment("generation-a"); - - route.accept({ revision: 1, phase: "prepare", assignment: failedAssignment }); - await route.acceptConfig(sfuConfig(1)); - await route.acceptAndWait({ - revision: 1, - phase: "active", - assignment: failedAssignment, - }); - expect(route.getQualityWarning()).toBe( - "SFU 传输失败,已启动自动恢复", - ); - - await route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: hostAssignment(null), - }); - expect(route.getQualityWarning()).toBeNull(); - }); - - it("never overlaps more than two host media edges while changing route kinds", async () => { - const log: string[] = []; - const edgeCounts: number[] = []; - const publishers: Array<{ - connect: ReturnType; - activate: ReturnType; - deactivate: ReturnType; - replaceStream: ReturnType; - updateProfile: ReturnType; - disconnect: ReturnType; - }> = []; - let directEdges = 0; - let sfuEdges = 0; - const record = (event: string) => { - log.push(event); - edgeCounts.push(directEdges + sfuEdges); - }; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: (children) => { - directEdges = children.length; - record(`children:${children.join(",")}`); - }, - send: () => true, - createPublisher: () => { - const label = `publisher-${publishers.length + 1}`; - const publisher = { - connect: vi.fn(async () => true), - activate: vi.fn(async () => { - sfuEdges = 1; - record(`${label}:activate`); - return true; - }), - deactivate: vi.fn(async () => { - sfuEdges = 0; - record(`${label}:deactivate`); - return true; - }), - replaceStream: vi.fn(async () => true), - updateProfile: vi.fn(async () => true), - disconnect: vi.fn(async () => record(`${label}:disconnect`)), - }; - publishers.push(publisher); - return publisher; - }, - }); - - route.accept({ - revision: 1, - phase: "active", - assignment: hostAssignment(null, ["direct-a", "direct-b"]), - }); - await vi.waitFor(() => expect(log).toContain("children:direct-a,direct-b")); - - const generationA = hostAssignment("generation-a", ["direct-a"]); - route.accept({ revision: 2, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(2)); - log.length = 0; - route.accept({ revision: 2, phase: "active", assignment: generationA }); - await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); - expect(log.slice(0, 2)).toEqual([ - "children:direct-a", - "publisher-1:activate", - ]); - - const generationB = hostAssignment("generation-b", ["direct-b"]); - route.accept({ revision: 3, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(3)); - log.length = 0; - route.accept({ revision: 3, phase: "active", assignment: generationB }); - await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); - expect(log.slice(0, 4)).toEqual([ - "children:direct-b", - "publisher-1:deactivate", - "publisher-1:disconnect", - "publisher-2:activate", - ]); - - log.length = 0; - route.accept({ - revision: 4, - phase: "active", - assignment: hostAssignment(null, ["direct-a", "direct-b"]), - }); - await vi.waitFor(() => - expect(log).toContain("children:direct-a,direct-b"), - ); - expect(log.slice(0, 3)).toEqual([ - "publisher-2:deactivate", - "publisher-2:disconnect", - "children:direct-a,direct-b", - ]); - expect(Math.max(...edgeCounts)).toBeLessThanOrEqual(2); - }); - - it("serializes continuous active revisions while the old publisher retires", async () => { - const publishers: ReturnType[] = []; - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - createPublisher: () => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - return publisher; - }, - }); - - const generationA = hostAssignment("generation-a", ["direct-a"]); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment: generationA }); - await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); - - let finishOldDeactivate!: () => void; - let oldDeactivateFinished = false; - const oldDeactivateGate = new Promise((resolve) => { - finishOldDeactivate = resolve; - }); - publishers[0]?.deactivate.mockImplementation(async () => { - await oldDeactivateGate; - oldDeactivateFinished = true; - return true; - }); - - const generationB = hostAssignment("generation-b", ["direct-b"]); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - route.accept({ revision: 2, phase: "active", assignment: generationB }); - await vi.waitFor(() => expect(publishers[0]?.deactivate).toHaveBeenCalled()); - - const generationC = hostAssignment("generation-c", ["direct-c"]); - route.accept({ revision: 3, phase: "prepare", assignment: generationC }); - await route.acceptConfig(sfuConfig(3)); - route.accept({ revision: 3, phase: "active", assignment: generationC }); - - expect(publishers[2]?.disconnect).not.toHaveBeenCalled(); - expect(publishers[1]?.activate).not.toHaveBeenCalled(); - expect(publishers[2]?.activate).not.toHaveBeenCalled(); - - finishOldDeactivate(); - await vi.waitFor(() => expect(oldDeactivateFinished).toBe(true)); - expect(publishers[2]?.disconnect).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(publishers[2]?.activate).toHaveBeenCalledOnce()); - expect(publishers[1]?.activate).not.toHaveBeenCalled(); - }); - - it("releases active ownership when a published activation becomes stale", async () => { - const streamA = {} as MediaStream; - const streamB = {} as MediaStream; - let currentStream = streamA; - const publishers: ReturnType[] = []; - const route = new HostSfuRoute({ - getStream: () => currentStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: () => true, - createPublisher: () => { - const publisher = createFakePublisher( - [], - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - return publisher; - }, - }); - - const generationA = hostAssignment("generation-a"); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - - let finishReplace!: () => void; - const replaceGate = new Promise((resolve) => { - finishReplace = resolve; - }); - publishers[0]?.activate.mockImplementation(async () => { - currentStream = streamB; - return true; - }); - publishers[0]?.replaceStream.mockImplementation(async () => { - await replaceGate; - return true; - }); - - route.accept({ revision: 1, phase: "active", assignment: generationA }); - await vi.waitFor(() => expect(publishers[0]?.replaceStream).toHaveBeenCalled()); - - const generationB = hostAssignment("generation-b"); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - finishReplace(); - await vi.waitFor(() => expect(publishers[0]?.disconnect).toHaveBeenCalled()); - - await expect( - route.updateProfile(QUALITY_PROFILES["1080p30"]), - ).resolves.toBe(true); - expect(publishers[0]?.updateProfile).not.toHaveBeenCalled(); - expect(publishers[1]?.disconnect).not.toHaveBeenCalled(); - - route.accept({ revision: 2, phase: "active", assignment: generationB }); - await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); - }); - - it("keeps an activating publisher on a duplicate active update", async () => { - const messages: ClientMessage[] = []; - const publisher = createFakePublisher([], "publisher"); - let finishActivate!: () => void; - const activateGate = new Promise((resolve) => { - finishActivate = resolve; - }); - publisher.activate.mockImplementation(async () => { - await activateGate; - return true; - }); - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: () => publisher, - }); - const assignment = hostAssignment("generation-a"); - - route.accept({ revision: 1, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment }); - await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce()); - - expect( - route.accept({ revision: 1, phase: "active", assignment }), - ).toBe("duplicate"); - expect(publisher.activate).toHaveBeenCalledOnce(); - expect(publisher.disconnect).not.toHaveBeenCalled(); - - finishActivate(); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-ready", - revision: 1, - phase: "active", - }), - ); - expect(publisher.disconnect).not.toHaveBeenCalled(); - }); - - it("prepares without media, commits in order, and reuses a publication generation", async () => { + it("uses the subscriber first decoded frame as the only SFU prepare ready", async () => { const log: string[] = []; const messages: ClientMessage[] = []; - const publishers: ReturnType[] = []; - const stream = {} as MediaStream; - const route = new HostSfuRoute({ - getStream: () => stream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: (children) => log.push(`children:${children.join(",")}`), - send: (message) => { - messages.push(message); - return true; - }, - createPublisher: () => { - const publisher = createFakePublisher( - log, - `publisher-${publishers.length + 1}`, - ); - publishers.push(publisher); - return publisher; - }, - }); - - const generationA = hostAssignment("generation-a", ["direct-a"]); - route.accept({ revision: 1, phase: "prepare", assignment: generationA }); - await route.acceptConfig(sfuConfig(1)); - - expect(publishers[0]?.activate).not.toHaveBeenCalled(); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 1, - phase: "prepare", - }); - - route.accept({ revision: 1, phase: "active", assignment: generationA }); - await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce()); - - const generationB = hostAssignment("generation-b", ["direct-b"]); - route.accept({ revision: 2, phase: "prepare", assignment: generationB }); - await route.acceptConfig(sfuConfig(2)); - expect(publishers[0]?.deactivate).not.toHaveBeenCalled(); - expect(publishers[1]?.activate).not.toHaveBeenCalled(); - - log.length = 0; - route.accept({ revision: 2, phase: "active", assignment: generationB }); - await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce()); - expect(log.slice(0, 4)).toEqual([ - "children:direct-b", - "publisher-1:deactivate", - "publisher-1:disconnect", - "publisher-2:activate", - ]); - - route.accept({ - revision: 3, - phase: "active", - assignment: hostAssignment("generation-b", ["direct-c"]), - }); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-ready", - revision: 3, - phase: "active", - }), - ); - expect(publishers).toHaveLength(2); - expect(publishers[1]?.deactivate).not.toHaveBeenCalled(); - - route.accept({ - revision: 4, - phase: "prepare", - assignment: hostAssignment("generation-c"), - }); - await route.acceptConfig(sfuConfig(4)); - route.accept({ - revision: 5, - phase: "active", - assignment: hostAssignment("generation-b", ["direct-c"]), - }); - await vi.waitFor(() => expect(publishers[2]?.disconnect).toHaveBeenCalled()); - expect(publishers[1]?.deactivate).not.toHaveBeenCalled(); - }); - - it("retires old media before applying a lower authoritative host revision", async () => { - const log: string[] = []; - const publisher = createFakePublisher(log, "publisher"); - const route = new HostSfuRoute({ - getStream: () => ({}) as MediaStream, - getProfile: () => QUALITY_PROFILES["720p30"], - reconcileChildren: (children) => - log.push(`children:${children.join(",")}`), - send: () => true, - createPublisher: () => publisher, - }); - - const oldAssignment = hostAssignment("generation-old"); - route.accept({ revision: 10, phase: "prepare", assignment: oldAssignment }); - await route.acceptConfig(sfuConfig(10)); - route.accept({ revision: 10, phase: "active", assignment: oldAssignment }); - await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce()); - - let finishDeactivate!: () => void; - const deactivateGate = new Promise((resolve) => { - finishDeactivate = resolve; - }); - publisher.deactivate.mockImplementation(async () => { - log.push("publisher:deactivate"); - await deactivateGate; - return true; - }); - log.length = 0; - - const authoritative = hostAssignment(null, ["new-child-a", "new-child-b"]); - const resync = route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: authoritative, - }); - await vi.waitFor(() => expect(publisher.deactivate).toHaveBeenCalledOnce()); - expect(log).toEqual(["children:", "publisher:deactivate"]); - - finishDeactivate(); - await expect(resync).resolves.toBe("accepted"); - expect(log).toEqual([ - "children:", - "publisher:deactivate", - "publisher:disconnect", - "children:new-child-a,new-child-b", - ]); - - await expect( - route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: authoritative, - }), - ).resolves.toBe("duplicate"); - expect(publisher.disconnect).toHaveBeenCalledOnce(); - }); -}); - -describe("ViewerSfuRoute", () => { - it("retains an active SFU subscriber when the root has zero peer children", async () => { - const reconcileSfuChildren = vi.fn(); - const { route, subscriber } = await activateViewerSfuRoute({ - activatePeer: () => undefined, - reconcileSfuChildren, - onSfuStream: () => undefined, - send: () => true, - }); - reconcileSfuChildren.mockClear(); - - route.accept({ - revision: 2, - phase: "active", - assignment: sfuAssignment(), - }); - - await vi.waitFor(() => - expect(reconcileSfuChildren).toHaveBeenCalledWith([]), - ); - expect(subscriber.activate).toHaveBeenCalledOnce(); - expect(subscriber.deactivate).not.toHaveBeenCalled(); - expect(subscriber.disconnect).not.toHaveBeenCalled(); - }); - - it("reports a subscriber prepare failure without requesting refresh", async () => { - const messages: ClientMessage[] = []; - const subscriber = createFakeSubscriber( - { onStream: () => undefined, onDisconnected: () => undefined }, - [], - "subscriber", - ); - subscriber.connect.mockResolvedValue(false); - const route = new ViewerSfuRoute({ - activatePeer: () => undefined, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createSubscriber: () => subscriber, - }); - - route.accept({ - revision: 1, - phase: "prepare", - assignment: sfuAssignment(), - }); - await route.acceptConfig(sfuConfig(1)); - - expect(messages).toEqual([ - { - type: "route-failed", - revision: 1, - phase: "prepare", - connectionId: null, - }, - ]); - }); - - it("reports the pending peer revision when the active subscriber fails", async () => { - const messages: ClientMessage[] = []; - const { route, subscriber } = await activateViewerSfuRoute({ - activatePeer: () => undefined, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - }); - - messages.length = 0; - route.accept({ - revision: 2, - phase: "prepare", - assignment: peerAssignment("host-peer"), - }); - subscriber.events.onDisconnected(); - - expect(messages).toEqual([{ - type: "route-failed", - revision: 2, - phase: "prepare", - connectionId: null, - }]); - }); - - it("buffers an assigned parent offer until SFU fallback retirement completes", async () => { - const log: string[] = []; - const bufferedSignals: string[] = []; - const deliveredSignals: string[] = []; - let preparedParentPeerId: string | null = null; + const streams: MediaStream[] = []; const subscribers: ReturnType[] = []; const route = new ViewerSfuRoute({ - activatePeer: (assignment) => { - const peerId = - assignment.upstream.kind === "peer" - ? assignment.upstream.peerId - : "none"; - log.push(`peer:${peerId}`); - if (peerId === preparedParentPeerId) { - deliveredSignals.push(...bufferedSignals.splice(0)); - preparedParentPeerId = null; - } - }, - preparePeer: (assignment) => { - preparedParentPeerId = - assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null; - }, + activatePeer: () => true, reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: () => true, + onSfuStream: (stream) => streams.push(stream), + send: (message) => { + messages.push(message); + return true; + }, createSubscriber: (events) => { const subscriber = createFakeSubscriber( events, @@ -1575,819 +352,84 @@ describe("ViewerSfuRoute", () => { return subscriber; }, }); - const sfu = sfuAssignment(); - route.accept({ revision: 1, phase: "prepare", assignment: sfu }); + route.accept({ + revision: 1, + phase: "active", + assignment: viewerSfuAssignment(), + }); await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment: sfu }); - await vi.waitFor(() => - expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), - ); - subscribers[0]?.events.onStream({} as MediaStream); - - let finishDisconnect!: () => void; - const disconnectGate = new Promise((resolve) => { - finishDisconnect = resolve; - }); - subscribers[0]?.disconnect.mockImplementation(async () => { - log.push("subscriber-1:disconnect"); - await disconnectGate; - }); - - log.length = 0; - route.accept({ - revision: 2, - phase: "active", - assignment: peerAssignment("host-peer"), - }); - expect(preparedParentPeerId).toBe("host-peer"); - bufferedSignals.push("early-offer"); - expect(deliveredSignals).toEqual([]); - await vi.waitFor(() => - expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(), - ); - expect(log).toEqual([ - "subscriber-1:deactivate", - "subscriber-1:disconnect", - ]); - - finishDisconnect(); - await vi.waitFor(() => expect(log).toContain("peer:host-peer")); - expect(log.slice(0, 3)).toEqual([ - "subscriber-1:deactivate", - "subscriber-1:disconnect", - "peer:host-peer", - ]); - expect(deliveredSignals).toEqual(["early-offer"]); - }); - - it("promotes a proven peer probe before retiring active SFU media", async () => { - const log: string[] = []; - let probeReady = false; - const prepared: Array<{ parent: string | null; revision?: number }> = []; - const activatePeer = vi.fn( - async (assignment: ParticipantRouteAssignment, revision?: number) => { - const parent = assignment.upstream.kind === "peer" - ? assignment.upstream.peerId - : assignment.upstream.kind; - log.push(`peer:${parent}:${revision ?? "active"}`); - return revision === undefined ? undefined : probeReady; - }, - ); - const { route, subscriber } = await activateViewerSfuRoute({ - activatePeer, - preparePeer: (assignment, revision) => prepared.push({ - parent: assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null, - ...(revision === undefined ? {} : { revision }), - }), - reconcileSfuChildren: (children) => log.push(`children:${children.join(",")}`), - onSfuStream: () => undefined, - send: () => true, - }, log); - log.length = 0; - - const peer = peerAssignment("host-peer", ["relay-child"]); - route.accept({ revision: 2, phase: "prepare", assignment: peer }); - expect(prepared).toContainEqual({ parent: "host-peer", revision: 2 }); - expect(log).not.toContain("children:relay-child"); - expect(subscriber.deactivate).not.toHaveBeenCalled(); - - route.accept({ revision: 2, phase: "active", assignment: peer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(peer, 2)); - expect(subscriber.deactivate).not.toHaveBeenCalled(); - expect(subscriber.disconnect).not.toHaveBeenCalled(); - - probeReady = true; - route.accept({ revision: 2, phase: "active", assignment: peer }); - await vi.waitFor(() => expect(subscriber.disconnect).toHaveBeenCalledOnce()); - expect(log.indexOf("peer:host-peer:2")).toBeLessThan( - log.indexOf("subscriber:deactivate"), - ); - expect(log).toContain("children:relay-child"); - expect(activatePeer).not.toHaveBeenCalledWith(peer); - }); - - it.each([ - ["peer Viewer", peerAssignment("current-parent", ["provisional-child"])], - ["SFU root Viewer", sfuAssignment(["provisional-child"])], - ])("uses the prepare/active child lifecycle for a %s", async (_label, assignment) => { - const prepared: Array<{ - revision?: number; - children: readonly string[] | null; - }> = []; - const activated: Array<{ - revision: number; - children: readonly string[]; - }> = []; - const route = new ViewerSfuRoute({ - activatePeer: () => undefined, - prepareChild: (children, revision) => prepared.push({ - children, - ...(revision === undefined ? {} : { revision }), - }), - activateChildren: (children, revision) => - activated.push({ children, revision }), - onSfuStream: () => undefined, - send: () => true, - }); - - route.accept({ - revision: 1, - phase: "active", - assignment: { ...assignment, childPeerIds: [] }, - }); - await vi.waitFor(() => expect(activated).toEqual([{ - revision: 1, - children: [], - }])); - prepared.length = 0; - activated.length = 0; - route.accept({ revision: 2, phase: "prepare", assignment }); - expect(prepared).toEqual([{ - revision: 2, - children: ["provisional-child"], - }]); - route.accept({ revision: 2, phase: "active", assignment }); - await vi.waitFor(() => expect(activated).toEqual([{ - revision: 2, - children: ["provisional-child"], - }])); - }); - - it("prepares a second peer without replacing the active peer before commit", async () => { - const prepared: Array<{ parent: string | null; revision?: number }> = []; - const activatePeer = vi.fn(); - const route = new ViewerSfuRoute({ - activatePeer, - preparePeer: (assignment, revision) => prepared.push({ - parent: assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null, - ...(revision === undefined ? {} : { revision }), - }), - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: () => true, - }); - const oldPeer = peerAssignment("old-parent"); - const newPeer = peerAssignment("new-parent"); - - route.accept({ revision: 1, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer)); - activatePeer.mockClear(); - route.accept({ revision: 2, phase: "prepare", assignment: newPeer }); - expect(prepared.at(-1)).toEqual({ parent: "new-parent", revision: 2 }); - expect(activatePeer).not.toHaveBeenCalled(); - - route.accept({ revision: 3, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer)); - expect(activatePeer).not.toHaveBeenCalledWith(newPeer, 2); - }); - - it("reports an active failure instead of promoting a failed ready peer probe", async () => { - const sent: ClientMessage[] = []; - const activatePeer = vi.fn(() => true); - const route = new ViewerSfuRoute({ - activatePeer, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { sent.push(message); return true; }, - }); - const oldPeer = peerAssignment("old-parent"); - const newPeer = peerAssignment("new-parent"); - - route.accept({ revision: 1, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer)); - activatePeer.mockClear(); - route.accept({ revision: 2, phase: "prepare", assignment: newPeer }); - expect(route.reportPeerProbeFailure("new-parent", "failed-probe", true)).toBe(true); - expect(sent).toContainEqual({ type: "route-failed", revision: 2, - phase: "prepare", connectionId: "failed-probe" }); - - route.accept({ revision: 2, phase: "active", assignment: newPeer }); - await vi.waitFor(() => expect(sent).toContainEqual({ type: "route-failed", - revision: 2, phase: "active", connectionId: "failed-probe" })); - expect(activatePeer).not.toHaveBeenCalled(); - }); - - it("blocks promotion when a ready probe fails after active is accepted", async () => { - const sent: ClientMessage[] = []; - const activatePeer = vi.fn(() => true); - const route = new ViewerSfuRoute({ - activatePeer, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { sent.push(message); return true; }, - }); - const oldPeer = peerAssignment("old-parent"); - const newPeer = peerAssignment("new-parent"); - - route.accept({ revision: 1, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer)); - activatePeer.mockClear(); - route.accept({ revision: 2, phase: "prepare", assignment: newPeer }); - route.accept({ revision: 2, phase: "active", assignment: newPeer }); - expect(route.reportPeerProbeFailure("new-parent", "accepted-failed-probe", true)).toBe(true); - await vi.waitFor(() => expect(sent).toContainEqual({ type: "route-failed", - revision: 2, phase: "active", connectionId: "accepted-failed-probe" })); - expect(activatePeer).not.toHaveBeenCalled(); - }); - - it("clears a failed peer probe tombstone on rollback", async () => { - const sent: ClientMessage[] = []; - const prepared: Array<{ parent: string | null; revision?: number }> = []; - const route = new ViewerSfuRoute({ - activatePeer: () => true, - preparePeer: (assignment, revision) => prepared.push({ - parent: assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null, - ...(revision === undefined ? {} : { revision }), - }), - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { sent.push(message); return true; }, - }); - const oldPeer = peerAssignment("old-parent"); - - route.accept({ revision: 1, phase: "active", assignment: oldPeer }); - route.accept({ revision: 2, phase: "prepare", assignment: peerAssignment("new-parent") }); - route.reportPeerProbeFailure("new-parent", "rolled-back-probe", true); - route.accept({ revision: 3, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(prepared.at(-1)).toEqual({ parent: "old-parent" })); - expect(sent).not.toContainEqual(expect.objectContaining({ - phase: "active", connectionId: "rolled-back-probe", - })); - }); - - it("clears a failed peer probe tombstone on authoritative resync", async () => { - const sent: ClientMessage[] = []; - const activatePeer = vi.fn(() => true); - const route = new ViewerSfuRoute({ - activatePeer, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { sent.push(message); return true; }, - }); - const oldPeer = peerAssignment("old-parent"); - const newPeer = peerAssignment("new-parent"); - - route.accept({ revision: 1, phase: "active", assignment: oldPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer)); - route.accept({ revision: 2, phase: "prepare", assignment: newPeer }); - route.reportPeerProbeFailure("new-parent", "resynced-probe", true); - activatePeer.mockClear(); - await route.resyncAuthoritative({ revision: 2, phase: "active", assignment: newPeer }); - await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(newPeer)); - expect(sent).not.toContainEqual(expect.objectContaining({ - phase: "active", connectionId: "resynced-probe", - })); - }); - - it("requests provisional peer teardown on a newer SFU rollback", async () => { - const prepared: Array<{ parent: string | null; revision?: number }> = []; - const { route, subscriber } = await activateViewerSfuRoute({ - activatePeer: () => undefined, - preparePeer: (assignment, revision) => prepared.push({ - parent: assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null, - ...(revision === undefined ? {} : { revision }), - }), - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: () => true, - }); + const firstStream = {} as MediaStream; + subscribers[0]?.events.onStream(firstStream); + subscribers[0]?.events.onStats?.({ + intervalFramesDecoded: 1, + } as ConnectionMetrics); + await vi.waitFor(() => expect(streams).toEqual([firstStream])); route.accept({ revision: 2, phase: "prepare", - assignment: peerAssignment("host-peer"), + assignment: viewerSfuAssignment(), }); - expect(prepared.at(-1)).toEqual({ parent: "host-peer", revision: 2 }); - route.accept({ revision: 3, phase: "active", assignment: sfuAssignment() }); - expect(prepared.at(-1)).toEqual({ parent: null }); - expect(subscriber.deactivate).not.toHaveBeenCalled(); + await route.acceptConfig(sfuConfig(2)); + const pendingStream = {} as MediaStream; + subscribers[1]?.events.onStream(pendingStream); + subscribers[1]?.events.onStats?.({ + intervalFramesDecoded: 0, + } as ConnectionMetrics); + expect(messages.filter((message) => message.type === "route-ready")).toEqual([]); + + subscribers[1]?.events.onStats?.({ + intervalFramesDecoded: 2, + } as ConnectionMetrics); + subscribers[1]?.events.onStats?.({ + intervalFramesDecoded: 3, + } as ConnectionMetrics); + expect(messages.filter((message) => message.type === "route-ready")).toEqual([ + { type: "route-ready", revision: 2, phase: "prepare" }, + ]); + expect(streams).toEqual([firstStream]); + + route.accept({ + revision: 2, + phase: "active", + assignment: viewerSfuAssignment(), + }); + await vi.waitFor(() => expect(streams).toEqual([firstStream, pendingStream])); + expect(subscribers[0]?.deactivate).toHaveBeenCalledOnce(); + expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(); }); - it("re-proves a sticky SFU route after a cooldown-bound authority reassertion", async () => { - const healthy: number[] = []; + it("discards a paused pending subscriber and ignores its later evidence", async () => { const messages: ClientMessage[] = []; let subscriber!: ReturnType; const route = new ViewerSfuRoute({ - activatePeer: () => undefined, + activatePeer: () => true, reconcileSfuChildren: () => undefined, onSfuStream: () => undefined, - onHealthySfu: (revision) => healthy.push(revision), send: (message) => { messages.push(message); return true; }, createSubscriber: (events) => { - subscriber = createFakeSubscriber(events, [], "subscriber"); + subscriber = createFakeSubscriber(events, [], "pending"); return subscriber; }, }); - await route.resyncAuthoritative({ - revision: 7, - phase: "active", - assignment: sfuAssignment(), - }); - route.armHealthySfuReselection(7); - await route.acceptConfig(sfuConfig(7)); - await vi.waitFor(() => expect(subscriber.activate).toHaveBeenCalledOnce()); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 7, - phase: "active", + route.accept({ + revision: 3, + phase: "prepare", + assignment: viewerSfuAssignment(), }); + await route.acceptConfig(sfuConfig(3)); + route.setPaused(true); + expect(subscriber.disconnect).toHaveBeenCalledOnce(); + subscriber.events.onStream({} as MediaStream); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 7, - phase: "active", - }); subscriber.events.onStats?.({ - intervalPacketsReceived: 4, - intervalFramesDecoded: 0, - } as ConnectionMetrics); - subscriber.events.onStats?.({ - intervalPacketsReceived: 4, - intervalFramesDecoded: 3, - } as ConnectionMetrics); - expect(healthy).toEqual([]); - - route.armHealthySfuReselection(7); - subscriber.events.onStats?.({ - intervalPacketsReceived: 4, - intervalFramesDecoded: 3, - } as ConnectionMetrics); - subscriber.events.onStats?.({ - intervalPacketsReceived: 2, - intervalFramesDecoded: 1, - } as ConnectionMetrics); - expect(healthy).toEqual([7]); - - expect(route.accept({ - revision: 7, - phase: "active", - assignment: sfuAssignment(), - })).toBe("duplicate"); - subscriber.events.onStats?.({ - intervalPacketsReceived: 3, - intervalFramesDecoded: 2, - } as ConnectionMetrics); - subscriber.events.onStats?.({ - intervalPacketsReceived: 5, intervalFramesDecoded: 4, } as ConnectionMetrics); - expect(healthy).toEqual([7, 7]); - }); - - it("switches on first SFU video and forwards loss across same-publication revision reuse", async () => { - const log: string[] = []; - const messages: ClientMessage[] = []; - const peerAssignments: ParticipantRouteAssignment[] = []; - const streams: Array<{ - stream: MediaStream; - initialVideoStream: boolean; - assignment: ParticipantRouteAssignment; - }> = []; - const sfuUpdates: Array = []; - const sfuStates: string[] = []; - const videoAvailability: boolean[] = []; - const metrics = {} as ConnectionMetrics; - const subscribers: ReturnType[] = []; - const route = new ViewerSfuRoute({ - activatePeer: (assignment) => { - peerAssignments.push(assignment); - }, - reconcileSfuChildren: (children) => log.push(`children:${children.join(",")}`), - onSfuStream: (stream, assignment, initialVideoStream) => { - streams.push({ stream, assignment, initialVideoStream }); - }, - onSfuUpdate: (update) => sfuUpdates.push(update), - onSfuState: (state) => sfuStates.push(state), - onSfuVideoAvailability: (available) => - videoAvailability.push(available), - send: (message) => { - messages.push(message); - return true; - }, - createSubscriber: (events) => { - const subscriber = createFakeSubscriber( - events, - log, - `subscriber-${subscribers.length + 1}`, - ); - subscribers.push(subscriber); - return subscriber; - }, - }); - - route.accept({ - revision: 1, - phase: "active", - assignment: peerAssignment("host-peer"), - }); - await vi.waitFor(() => expect(peerAssignments).toHaveLength(1)); - const nextAssignment = sfuAssignment(["relay-child"]); - route.accept({ revision: 2, phase: "prepare", assignment: nextAssignment }); - await route.acceptConfig(sfuConfig(2)); - - expect(peerAssignments).toHaveLength(1); - expect(streams).toHaveLength(0); - expect(subscribers[0]?.activate).not.toHaveBeenCalled(); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 2, - phase: "prepare", - }); - - route.accept({ revision: 2, phase: "active", assignment: nextAssignment }); - await vi.waitFor(() => - expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), - ); - expect(streams).toHaveLength(0); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - - subscribers[0]?.events.onVideoAvailability?.(false); - subscribers[0]?.events.onVideoAvailability?.(true); - expect(videoAvailability).toEqual([]); - subscribers[0]?.events.onStats?.(metrics); - subscribers[0]?.events.onState?.("reconnecting"); - expect(sfuUpdates).toEqual([]); - expect(sfuStates).toEqual([]); - - const stream = {} as MediaStream; - subscribers[0]?.events.onStream(stream); - subscribers[0]?.events.onStats?.(metrics); - subscribers[0]?.events.onState?.("reconnecting"); - expect(sfuUpdates).toEqual([metrics]); - expect(sfuStates).toEqual(["reconnecting"]); - expect(streams).toEqual([ - { stream, assignment: nextAssignment, initialVideoStream: true }, - ]); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - - subscribers[0]?.events.onVideoAvailability?.(false); - subscribers[0]?.events.onVideoAvailability?.(true); - expect(videoAvailability).toEqual([false, true]); - expect(messages).toContainEqual({ - type: "route-media-unavailable", - revision: 2, - }); - expect( - messages.filter( - (message) => - message.type === "route-ready" && - message.revision === 2 && - message.phase === "active", - ), - ).toHaveLength(2); - - const revisionThreeAssignment = sfuAssignment(["other-child"]); - route.accept({ - revision: 3, - phase: "active", - assignment: revisionThreeAssignment, - }); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-ready", - revision: 3, - phase: "active", - }), - ); - expect(subscribers).toHaveLength(1); - expect(subscribers[0]?.deactivate).not.toHaveBeenCalled(); - subscribers[0]?.events.onVideoAvailability?.(false); - expect(videoAvailability).toEqual([false, true, false]); - expect(messages).toContainEqual({ - type: "route-media-unavailable", - revision: 3, - }); - - route.accept({ - revision: 4, - phase: "prepare", - assignment: sfuAssignment(), - }); - await route.acceptConfig(sfuConfig(4)); - route.accept({ - revision: 5, - phase: "active", - assignment: sfuAssignment(["other-child"]), - }); - await vi.waitFor(() => expect(subscribers[1]?.disconnect).toHaveBeenCalled()); - expect(subscribers[0]?.deactivate).not.toHaveBeenCalled(); - expect(streams).toHaveLength(1); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 5, - phase: "active", - }); - - await route.disconnect(); - expect(sfuUpdates).toEqual([metrics, null]); - subscribers[0]?.events.onStats?.(metrics); - subscribers[0]?.events.onState?.("connected"); - subscribers[0]?.events.onVideoAvailability?.(false); - expect(sfuUpdates).toEqual([metrics, null]); - expect(sfuStates).toEqual(["reconnecting"]); - expect(videoAvailability).toEqual([false, true, false]); - }); - - it("rebuilds an authoritative SFU route before proving a replaced publication", async () => { - const log: string[] = []; - const messages: ClientMessage[] = []; - const resetMedia = vi.fn(); - const subscribers: ReturnType[] = []; - const route = new ViewerSfuRoute({ - activatePeer: () => undefined, - resetMedia, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createSubscriber: (events) => { - const subscriber = createFakeSubscriber( - events, - log, - `subscriber-${subscribers.length + 1}`, - ); - subscribers.push(subscriber); - return subscriber; - }, - }); - const assignment = sfuAssignment(); - - route.accept({ revision: 1, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(1)); - route.accept({ revision: 1, phase: "active", assignment }); - await vi.waitFor(() => - expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), - ); - subscribers[0]?.events.onStream({} as MediaStream); - messages.length = 0; - log.length = 0; - - await expect( - route.resyncAuthoritative({ - revision: 2, - phase: "active", - assignment, - }), - ).resolves.toBe("accepted"); - - expect(resetMedia).toHaveBeenCalledOnce(); - expect(log).toEqual([ - "subscriber-1:deactivate", - "subscriber-1:disconnect", - ]); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - - await route.acceptConfig(sfuConfig(2)); - await vi.waitFor(() => - expect(subscribers[1]?.activate).toHaveBeenCalledOnce(), - ); - expect(subscribers).toHaveLength(2); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - - subscribers[1]?.events.onStream({} as MediaStream); - expect(messages).toContainEqual({ - type: "route-ready", - revision: 2, - phase: "active", - }); - - messages.length = 0; - route.accept({ revision: 3, phase: "active", assignment }); - await vi.waitFor(() => - expect(messages).toContainEqual({ - type: "route-ready", - revision: 3, - phase: "active", - }), - ); - expect(subscribers).toHaveLength(2); - expect(subscribers[1]?.deactivate).not.toHaveBeenCalled(); - }); - - it("disconnects a subscriber whose connect completes after rollback", async () => { - let finishConnect!: () => void; - const connectGate = new Promise((resolve) => { - finishConnect = resolve; - }); - const messages: ClientMessage[] = []; - const subscriber = { - connect: vi.fn(async () => { - await connectGate; - return true; - }), - activate: vi.fn(() => true), - deactivate: vi.fn(() => true), - disconnect: vi.fn(async () => undefined), - }; - const route = new ViewerSfuRoute({ - activatePeer: () => undefined, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createSubscriber: () => subscriber, - }); - - route.accept({ - revision: 10, - phase: "prepare", - assignment: sfuAssignment(), - }); - const connecting = route.acceptConfig(sfuConfig(10)); - route.accept({ - revision: 11, - phase: "active", - assignment: peerAssignment("host-peer"), - }); - finishConnect(); - await connecting; - - expect(subscriber.activate).not.toHaveBeenCalled(); - expect(subscriber.disconnect).toHaveBeenCalled(); - expect(messages).not.toContainEqual({ - type: "route-ready", - revision: 10, - phase: "prepare", - }); - }); - - it("refreshes once after terminal loss and reports a failed rebuild", async () => { - const messages: ClientMessage[] = []; - const subscribers: ReturnType[] = []; - const route = new ViewerSfuRoute({ - activatePeer: () => undefined, - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: (message) => { - messages.push(message); - return true; - }, - createSubscriber: (events) => { - const subscriber = createFakeSubscriber( - events, - [], - `subscriber-${subscribers.length + 1}`, - ); - if (subscribers.length === 1) { - subscriber.connect.mockResolvedValue(false); - } - subscribers.push(subscriber); - return subscriber; - }, - }); - const assignment = sfuAssignment(); - - route.accept({ revision: 20, phase: "prepare", assignment }); - await route.acceptConfig(sfuConfig(20)); - route.accept({ revision: 20, phase: "active", assignment }); - await vi.waitFor(() => - expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), - ); - subscribers[0]?.events.onStream({} as MediaStream); - messages.length = 0; - - subscribers[0]?.events.onDisconnected(); - expect(messages).toEqual([{ type: "refresh-sfu", revision: 20 }]); - - await route.acceptConfig(sfuConfig(20)); - expect(messages).toEqual([ - { type: "refresh-sfu", revision: 20 }, - { - type: "route-failed", - revision: 20, - phase: "active", - connectionId: null, - }, - ]); - }); - - it("retires old media before applying a lower authoritative viewer revision", async () => { - const log: string[] = []; - const bufferedSignals: string[] = []; - const deliveredSignals: string[] = []; - let preparedParentPeerId: string | null = null; - const subscribers: ReturnType[] = []; - const route = new ViewerSfuRoute({ - activatePeer: (assignment) => { - const upstream = assignment.upstream; - log.push( - `peer:${upstream.kind === "peer" ? upstream.peerId : upstream.kind}`, - ); - if ( - upstream.kind === "peer" && - upstream.peerId === preparedParentPeerId - ) { - deliveredSignals.push(...bufferedSignals.splice(0)); - preparedParentPeerId = null; - } - }, - preparePeer: (assignment) => { - preparedParentPeerId = - assignment?.upstream.kind === "peer" - ? assignment.upstream.peerId - : null; - }, - resetMedia: () => log.push("reset-media"), - reconcileSfuChildren: () => undefined, - onSfuStream: () => undefined, - send: () => true, - createSubscriber: (events) => { - const subscriber = createFakeSubscriber( - events, - log, - `subscriber-${subscribers.length + 1}`, - ); - subscribers.push(subscriber); - return subscriber; - }, - }); - - route.accept({ revision: 10, phase: "prepare", assignment: sfuAssignment() }); - await route.acceptConfig(sfuConfig(10)); - route.accept({ revision: 10, phase: "active", assignment: sfuAssignment() }); - await vi.waitFor(() => - expect(subscribers[0]?.activate).toHaveBeenCalledOnce(), - ); - subscribers[0]?.events.onStream({} as MediaStream); - log.length = 0; - - let finishDisconnect!: () => void; - const disconnectGate = new Promise((resolve) => { - finishDisconnect = resolve; - }); - subscribers[0]?.disconnect.mockImplementation(async () => { - log.push("subscriber-1:disconnect"); - await disconnectGate; - }); - - const authoritative = peerAssignment("new-host-peer"); - const resync = route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: authoritative, - }); - expect(preparedParentPeerId).toBe("new-host-peer"); - bufferedSignals.push("early-resync-offer"); - expect(deliveredSignals).toEqual([]); - await vi.waitFor(() => - expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(), - ); - expect(log).toEqual([ - "reset-media", - "subscriber-1:deactivate", - "subscriber-1:disconnect", - ]); - - finishDisconnect(); - await expect(resync).resolves.toBe("accepted"); - expect(log).toEqual([ - "reset-media", - "subscriber-1:deactivate", - "subscriber-1:disconnect", - "peer:new-host-peer", - ]); - expect(deliveredSignals).toEqual(["early-resync-offer"]); - - await expect( - route.resyncAuthoritative({ - revision: 1, - phase: "active", - assignment: authoritative, - }), - ).resolves.toBe("duplicate"); - expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(); + expect(messages.filter((message) => message.type === "route-ready")).toEqual([]); }); });