refactor(client): simplify route candidate proof
This commit is contained in:
1 parent
e2cd2aaab1
commit
cd534c7e68
9 files changed
+946
-3625
No files matched your search
@@ -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<string, ActiveSelectedHostChild>,
|
||||
peerId: string,
|
||||
connectionId: string,
|
||||
): ActiveSelectedHostChild | undefined {
|
||||
const child = children.get(peerId);
|
||||
return child?.connectionId === connectionId ? child : undefined;
|
||||
}
|
||||
|
||||
export function carryHostSelectedChild(
|
||||
children: Map<string, ActiveSelectedHostChild>,
|
||||
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<string, ActiveSelectedHostChild>,
|
||||
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<string>;
|
||||
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<HostPreparedChildResolution, { kind: "promote" }>
|
||||
| { 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<HostProvisionalPrepareInput, "iceConfig" | "stream" | "profile">,
|
||||
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<HostProvisionalPrepareInput, "iceConfig" | "stream" | "profile">,
|
||||
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])
|
||||
);
|
||||
}
|
||||
+122
-104
@@ -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<void> = Promise.resolve();
|
||||
private resyncGeneration = 0;
|
||||
private resyncing = false;
|
||||
private paused = false;
|
||||
private closed = false;
|
||||
private lastConfig: Extract<ServerMessage, { type: "sfu-config" }> | 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<RouteUpdateResult> {
|
||||
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<RouteUpdateResult> {
|
||||
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<ServerMessage, { type: "sfu-config" }>,
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
@@ -382,17 +418,14 @@ export class HostSfuRoute {
|
||||
});
|
||||
}
|
||||
|
||||
private queueActivation(
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
): Promise<void> {
|
||||
private queueActivation(token: RouteOperationToken): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> = 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<RouteUpdateResult> {
|
||||
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<ServerMessage, { type: "sfu-config" }>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
+21
-133
@@ -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<IceConfig | null>(null);
|
||||
const peersRef = useRef(new Map<string, HostPeer>());
|
||||
const retiredConnectionsRef = useRef(new Map<string, string>());
|
||||
const selectedHostChildrenRef = useRef(
|
||||
new Map<string, ActiveSelectedHostChild>(),
|
||||
);
|
||||
const hostProvisionalChildRef = useRef<HostProvisionalChild | null>(null);
|
||||
const failedActiveHostChildRef = useRef<FailedActiveHostChild | null>(null);
|
||||
const activeHostChildPeerIdsRef = useRef<string[]>([]);
|
||||
const hostPeerIdRef = useRef<string | null>(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<void> {
|
||||
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,
|
||||
];
|
||||
|
||||
+166
-211
@@ -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<ServerMessage, { type: "signal" }>
|
||||
> = [];
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<PreparedChild, "peer"> & { connectionId: string };
|
||||
|
||||
export class ViewerRelay {
|
||||
private childPeerIds: string[] = [];
|
||||
private stream: MediaStream | null = null;
|
||||
private readonly peers = new Map<string, HostPeer>();
|
||||
private readonly snapshots = new Map<string, PeerSnapshot>();
|
||||
private readonly retiredConnections = new Map<string, string>();
|
||||
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])
|
||||
);
|
||||
}
|
||||
+93
-567
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+218
-2176
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user