refactor(route): converge on one room controller
This commit is contained in:
1 parent
d6dcca5e8f
commit
10c1a31de6
27 files changed
+2640
-16185
No files matched your search
+1
-1
@@ -14,7 +14,7 @@
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"start": "node dist/server/server/index.js",
|
||||
"gate:peer-topology-loopback": "tsx scripts/peer-assisted-benchmark.ts",
|
||||
"gate:sfu-admission": "vitest run tests/sfu-resource-admission.test.ts tests/sfu-room-control.test.ts tests/media-route-controller.test.ts tests/livekit-token.test.ts tests/sfu-client.test.ts tests/route-transition.test.ts tests/peer-assisted-benchmark.test.ts tests/server-config.test.ts tests/server-http.test.ts tests/server-signal.test.ts",
|
||||
"gate:sfu-admission": "vitest run tests/room-route-controller.test.ts tests/hybrid-media-router.test.ts tests/sfu-resource-admission.test.ts tests/sfu-room-control.test.ts tests/livekit-token.test.ts tests/sfu-client.test.ts tests/route-transition.test.ts tests/server-config.test.ts tests/server-http.test.ts tests/server-signal.test.ts",
|
||||
"gate:access-privacy": "npm run build:client && vitest run tests/server-signal.test.ts && tsx scripts/access-privacy-gate.ts",
|
||||
"gate:turn-udp-allocation": "tsx scripts/turn-allocation-gate.ts",
|
||||
"probe:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export const ROUTE_DECODED_FRAME_STALL_MS = 15_000;
|
||||
|
||||
export class DecodedFrameStallDetector {
|
||||
private identity: string | null = null;
|
||||
private lastDecodedAt = 0;
|
||||
private reported = false;
|
||||
private paused = false;
|
||||
|
||||
setPaused(paused: boolean, nowMs = Date.now()): void {
|
||||
if (this.paused === paused) return;
|
||||
this.paused = paused;
|
||||
this.lastDecodedAt = nowMs;
|
||||
this.reported = false;
|
||||
}
|
||||
|
||||
observe(
|
||||
identity: string,
|
||||
framesDecodedDelta: number | null,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (identity !== this.identity) {
|
||||
this.identity = identity;
|
||||
this.lastDecodedAt = nowMs;
|
||||
this.reported = false;
|
||||
return false;
|
||||
}
|
||||
if (this.paused) {
|
||||
this.lastDecodedAt = nowMs;
|
||||
this.reported = false;
|
||||
return false;
|
||||
}
|
||||
if ((framesDecodedDelta ?? 0) > 0) {
|
||||
this.lastDecodedAt = nowMs;
|
||||
this.reported = false;
|
||||
return false;
|
||||
}
|
||||
if (this.reported || nowMs - this.lastDecodedAt < ROUTE_DECODED_FRAME_STALL_MS) {
|
||||
return false;
|
||||
}
|
||||
this.reported = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.identity = null;
|
||||
this.lastDecodedAt = 0;
|
||||
this.reported = false;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
IceConfig,
|
||||
ParticipantRouteAssignment,
|
||||
ServerMessage,
|
||||
PreparedRouteCandidate,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import { countEndpointMediaCopies } from "../../shared/media-copy-accounting";
|
||||
@@ -9,11 +9,6 @@ import { HostPeer } from "../webrtc/host-peer";
|
||||
import type { PeerSnapshot } from "../types";
|
||||
import type { QualityProfile } from "./quality";
|
||||
|
||||
type SelectedEdgeTurn = Extract<
|
||||
ServerMessage,
|
||||
{ type: "selected-edge-turn"; edgeKind: "peer-selected" }
|
||||
>;
|
||||
|
||||
interface HostProvisionalInput {
|
||||
revision: number;
|
||||
assignment: ParticipantRouteAssignment;
|
||||
@@ -22,6 +17,7 @@ interface HostProvisionalInput {
|
||||
}
|
||||
|
||||
interface HostProvisionalPrepareInput extends HostProvisionalInput {
|
||||
candidate: PreparedRouteCandidate;
|
||||
iceConfig: IceConfig;
|
||||
stream: MediaStream;
|
||||
profile: QualityProfile;
|
||||
@@ -40,7 +36,7 @@ export type HostPreparedChildActivation =
|
||||
|
||||
interface PreparedHostChild {
|
||||
revision: number;
|
||||
peerId: string;
|
||||
candidate: PreparedRouteCandidate;
|
||||
peer: HostPeer;
|
||||
failed: boolean;
|
||||
replacesConnectionId: string | null;
|
||||
@@ -49,6 +45,7 @@ interface PreparedHostChild {
|
||||
export class HostProvisionalChild {
|
||||
private prepared: PreparedHostChild | null = null;
|
||||
private preparedRevision: number | null = null;
|
||||
private preparedCandidate: PreparedRouteCandidate | null = null;
|
||||
private plannedChildPeerIds: string[] = [];
|
||||
private promotedPeer: HostPeer | null = null;
|
||||
private signalingPeer: HostPeer | null = null;
|
||||
@@ -63,75 +60,45 @@ export class HostProvisionalChild {
|
||||
}
|
||||
if (
|
||||
this.preparedRevision === input.revision &&
|
||||
sameCandidate(this.preparedCandidate, input.candidate) &&
|
||||
samePeerIds(this.plannedChildPeerIds, planned)
|
||||
) {
|
||||
return this.prepared ? !this.prepared.failed : true;
|
||||
}
|
||||
const peerId = planned.find(
|
||||
(candidate) => !input.activeChildPeerIds.includes(candidate),
|
||||
);
|
||||
|
||||
this.discard();
|
||||
this.preparedRevision = input.revision;
|
||||
this.preparedCandidate = { ...input.candidate };
|
||||
this.plannedChildPeerIds = planned;
|
||||
if (!peerId) {
|
||||
return true;
|
||||
}
|
||||
if (this.events.activeConnectionId?.(peerId)) {
|
||||
if (
|
||||
this.events.activeConnectionId?.(input.candidate.childPeerId) ===
|
||||
input.candidate.connectionId
|
||||
) {
|
||||
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;
|
||||
if (input.candidate.transport === "direct") {
|
||||
this.startPrepared(input.revision, input.candidate, input);
|
||||
}
|
||||
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,
|
||||
candidate: PreparedRouteCandidate,
|
||||
input: Pick<HostProvisionalPrepareInput, "iceConfig" | "stream" | "profile">,
|
||||
selectedTurn?: SelectedEdgeTurn,
|
||||
): void {
|
||||
let peer: HostPeer;
|
||||
peer = new HostPeer(
|
||||
peerId,
|
||||
selectedTurn
|
||||
? { iceServers: [selectedTurn.iceServer] }
|
||||
: input.iceConfig,
|
||||
candidate.childPeerId,
|
||||
input.iceConfig,
|
||||
input.stream,
|
||||
input.profile,
|
||||
{
|
||||
sendSignal: (targetPeerId, payload) =>
|
||||
this.signalingPeer === peer && targetPeerId === peerId
|
||||
this.signalingPeer === peer &&
|
||||
targetPeerId === candidate.childPeerId &&
|
||||
payload.connectionId === candidate.connectionId
|
||||
? this.events.sendSignal(targetPeerId, payload)
|
||||
: false,
|
||||
onUpdate: (snapshot) => {
|
||||
@@ -146,16 +113,17 @@ export class HostProvisionalChild {
|
||||
}
|
||||
},
|
||||
},
|
||||
selectedTurn !== undefined,
|
||||
selectedTurn?.newConnectionId,
|
||||
false,
|
||||
candidate.connectionId,
|
||||
);
|
||||
this.signalingPeer = peer;
|
||||
this.prepared = {
|
||||
revision,
|
||||
peerId,
|
||||
candidate: { ...candidate },
|
||||
peer,
|
||||
failed: false,
|
||||
replacesConnectionId: selectedTurn?.oldConnectionId ?? null,
|
||||
replacesConnectionId:
|
||||
this.events.activeConnectionId?.(candidate.childPeerId) ?? null,
|
||||
};
|
||||
void peer
|
||||
.start()
|
||||
@@ -172,7 +140,8 @@ export class HostProvisionalChild {
|
||||
return Boolean(
|
||||
prepared &&
|
||||
!prepared.failed &&
|
||||
prepared.peerId === fromPeerId &&
|
||||
prepared.candidate.childPeerId === fromPeerId &&
|
||||
prepared.candidate.connectionId === payload.connectionId &&
|
||||
prepared.peer.connectionId === payload.connectionId,
|
||||
);
|
||||
}
|
||||
@@ -212,22 +181,28 @@ export class HostProvisionalChild {
|
||||
activate(input: HostProvisionalInput): HostPreparedChildActivation {
|
||||
const prepared = this.prepared;
|
||||
const currentConnectionId = prepared
|
||||
? this.events.activeConnectionId?.(prepared.peerId) ?? null
|
||||
? this.events.activeConnectionId?.(prepared.candidate.childPeerId) ?? 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) ||
|
||||
this.preparedCandidate?.connectionId === prepared.candidate.connectionId &&
|
||||
input.assignment.childPeerIds.includes(prepared.candidate.childPeerId) &&
|
||||
(!input.activeChildPeerIds.includes(prepared.candidate.childPeerId) ||
|
||||
prepared.replacesConnectionId === currentConnectionId)
|
||||
) {
|
||||
this.prepared = null;
|
||||
this.preparedRevision = null;
|
||||
this.preparedCandidate = null;
|
||||
this.plannedChildPeerIds = [];
|
||||
this.promotedPeer = prepared.peer;
|
||||
return { kind: "promote", peerId: prepared.peerId, peer: prepared.peer };
|
||||
return {
|
||||
kind: "promote",
|
||||
peerId: prepared.candidate.childPeerId,
|
||||
peer: prepared.peer,
|
||||
};
|
||||
}
|
||||
this.discard();
|
||||
return { kind: "ordinary" };
|
||||
@@ -235,6 +210,7 @@ export class HostProvisionalChild {
|
||||
|
||||
discard(): void {
|
||||
this.preparedRevision = null;
|
||||
this.preparedCandidate = null;
|
||||
this.plannedChildPeerIds = [];
|
||||
this.discardPeer();
|
||||
}
|
||||
@@ -267,10 +243,12 @@ export class HostProvisionalChild {
|
||||
}
|
||||
|
||||
function validPlannedHostChildren(
|
||||
input: HostProvisionalInput,
|
||||
input: HostProvisionalPrepareInput,
|
||||
): string[] | null {
|
||||
const childPeerIds = [...new Set(input.assignment.childPeerIds)];
|
||||
if (
|
||||
input.candidate.transport !== "direct" ||
|
||||
!childPeerIds.includes(input.candidate.childPeerId) ||
|
||||
childPeerIds.length !== input.assignment.childPeerIds.length ||
|
||||
childPeerIds.length < input.activeChildPeerIds.length ||
|
||||
childPeerIds.length > input.activeChildPeerIds.length + 1 ||
|
||||
@@ -287,6 +265,17 @@ function validPlannedHostChildren(
|
||||
return childPeerIds;
|
||||
}
|
||||
|
||||
function sameCandidate(
|
||||
left: PreparedRouteCandidate | null,
|
||||
right: PreparedRouteCandidate,
|
||||
): boolean {
|
||||
return (
|
||||
left?.childPeerId === right.childPeerId &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.transport === right.transport
|
||||
);
|
||||
}
|
||||
|
||||
function samePeerIds(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
|
||||
@@ -193,8 +193,15 @@ export class HostSfuRoute {
|
||||
const token = this.route.token();
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const phase = this.route.getPhase();
|
||||
const candidate = this.route.getPreparedCandidate();
|
||||
const publicationGeneration = assignment?.sfuPublicationGeneration;
|
||||
if (!token || !phase || !assignment || !publicationGeneration) {
|
||||
if (
|
||||
!token ||
|
||||
!phase ||
|
||||
!assignment ||
|
||||
!publicationGeneration ||
|
||||
(phase === "prepare" && candidate?.transport !== "sfu")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastConfig = message;
|
||||
@@ -228,7 +235,7 @@ export class HostSfuRoute {
|
||||
const selectedEdgeTurn =
|
||||
this.selectedEdgeTurn?.revision === message.revision &&
|
||||
this.selectedEdgeTurn.publicationGeneration === publicationGeneration &&
|
||||
this.selectedEdgeTurn.oldConnectionId === publicationGeneration
|
||||
this.selectedEdgeTurn.newConnectionId === candidate?.connectionId
|
||||
? this.selectedEdgeTurn
|
||||
: null;
|
||||
this.selectedEdgeTurn = null;
|
||||
@@ -299,6 +306,8 @@ export class HostSfuRoute {
|
||||
this.closed ||
|
||||
!this.route.acceptsConfig(message.revision) ||
|
||||
this.route.getPhase() !== "prepare" ||
|
||||
this.route.getPreparedCandidate()?.transport !== "sfu" ||
|
||||
this.route.getPreparedCandidate()?.connectionId !== message.newConnectionId ||
|
||||
Date.parse(message.expiresAt) <= Date.now()
|
||||
) {
|
||||
return false;
|
||||
@@ -318,12 +327,6 @@ export class HostSfuRoute {
|
||||
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) {
|
||||
|
||||
@@ -2,13 +2,21 @@ import type {
|
||||
ClientMessage,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
PreparedRouteCandidate,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export interface RouteUpdateInput {
|
||||
revision: number;
|
||||
phase: MediaRoutePhase;
|
||||
assignment: ParticipantRouteAssignment;
|
||||
}
|
||||
export type RouteUpdateInput =
|
||||
| {
|
||||
revision: number;
|
||||
phase: "prepare";
|
||||
assignment: ParticipantRouteAssignment;
|
||||
candidate: PreparedRouteCandidate;
|
||||
}
|
||||
| {
|
||||
revision: number;
|
||||
phase: "active";
|
||||
assignment: ParticipantRouteAssignment;
|
||||
};
|
||||
|
||||
export interface RouteOperationToken {
|
||||
revision: number;
|
||||
@@ -35,6 +43,7 @@ export class MediaRouteTransition {
|
||||
private plannedAssignment: ParticipantRouteAssignment | null = null;
|
||||
private activeAssignment: ParticipantRouteAssignment | null = null;
|
||||
private mediaAssignment: ParticipantRouteAssignment | null = null;
|
||||
private preparedCandidate: PreparedRouteCandidate | null = null;
|
||||
private generation = 0;
|
||||
|
||||
accept(update: RouteUpdateInput): RouteUpdateResult {
|
||||
@@ -45,6 +54,9 @@ export class MediaRouteTransition {
|
||||
if (
|
||||
!this.plannedAssignment ||
|
||||
!sameAssignment(this.plannedAssignment, update.assignment) ||
|
||||
(this.phase === "prepare" &&
|
||||
update.phase === "prepare" &&
|
||||
!sameCandidate(this.preparedCandidate, update.candidate)) ||
|
||||
(this.phase === "active" && update.phase === "prepare")
|
||||
) {
|
||||
return "stale";
|
||||
@@ -57,6 +69,8 @@ export class MediaRouteTransition {
|
||||
this.revision = update.revision;
|
||||
this.phase = update.phase;
|
||||
this.plannedAssignment = cloneAssignment(update.assignment);
|
||||
this.preparedCandidate =
|
||||
update.phase === "prepare" ? { ...update.candidate } : null;
|
||||
if (update.phase === "active") {
|
||||
this.activeAssignment = cloneAssignment(update.assignment);
|
||||
}
|
||||
@@ -113,16 +127,32 @@ export class MediaRouteTransition {
|
||||
return this.mediaAssignment ? cloneAssignment(this.mediaAssignment) : null;
|
||||
}
|
||||
|
||||
getPreparedCandidate(): PreparedRouteCandidate | null {
|
||||
return this.preparedCandidate ? { ...this.preparedCandidate } : null;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.revision = -1;
|
||||
this.phase = null;
|
||||
this.plannedAssignment = null;
|
||||
this.activeAssignment = null;
|
||||
this.mediaAssignment = null;
|
||||
this.preparedCandidate = null;
|
||||
this.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function sameCandidate(
|
||||
left: PreparedRouteCandidate | null,
|
||||
right: PreparedRouteCandidate,
|
||||
): boolean {
|
||||
return (
|
||||
left?.childPeerId === right.childPeerId &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.transport === right.transport
|
||||
);
|
||||
}
|
||||
|
||||
export function reportActivePeerRouteFailure(
|
||||
route: MediaRouteTransition,
|
||||
parentPeerId: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ClientMessage,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
PreparedRouteCandidate,
|
||||
ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { SfuSubscriber } from "../sfu/subscriber";
|
||||
@@ -40,9 +41,14 @@ interface ViewerSfuRouteEvents {
|
||||
assignment: ParticipantRouteAssignment,
|
||||
revision?: number,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
preparePeer?: (assignment: ParticipantRouteAssignment | null, revision?: number) => void;
|
||||
preparePeer?: (
|
||||
assignment: ParticipantRouteAssignment | null,
|
||||
revision?: number,
|
||||
candidate?: PreparedRouteCandidate,
|
||||
) => void;
|
||||
prepareChild?: (
|
||||
childPeerIds: readonly string[] | null,
|
||||
candidate: PreparedRouteCandidate | null,
|
||||
childPeerIds?: readonly string[],
|
||||
revision?: number,
|
||||
) => void;
|
||||
activateChildren?: (
|
||||
@@ -83,7 +89,10 @@ export class ViewerSfuRoute {
|
||||
private paused = false;
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly events: ViewerSfuRouteEvents) {}
|
||||
constructor(
|
||||
private readonly viewerPeerId: string,
|
||||
private readonly events: ViewerSfuRouteEvents,
|
||||
) {}
|
||||
|
||||
accept(update: RouteUpdateInput, acknowledge = true): RouteUpdateResult {
|
||||
if (this.closed) {
|
||||
@@ -112,12 +121,21 @@ export class ViewerSfuRoute {
|
||||
}
|
||||
}
|
||||
if (update.phase === "prepare") {
|
||||
this.events.prepareChild?.(update.assignment.childPeerIds, update.revision);
|
||||
const mediaUpstream = this.route.getMediaAssignment()?.upstream;
|
||||
const candidate = update.candidate;
|
||||
const ownsUpstreamCandidate = candidate.childPeerId === this.viewerPeerId;
|
||||
const ownsChildCandidate =
|
||||
candidate.transport !== "sfu" &&
|
||||
update.assignment.childPeerIds.includes(candidate.childPeerId);
|
||||
this.events.prepareChild?.(
|
||||
ownsChildCandidate ? candidate : null,
|
||||
ownsChildCandidate ? update.assignment.childPeerIds : undefined,
|
||||
ownsChildCandidate ? update.revision : undefined,
|
||||
);
|
||||
const needsPeerCandidate =
|
||||
ownsUpstreamCandidate &&
|
||||
candidate.transport !== "sfu" &&
|
||||
update.assignment.upstream.kind === "peer" &&
|
||||
(mediaUpstream?.kind !== "peer" ||
|
||||
mediaUpstream.peerId !== update.assignment.upstream.peerId);
|
||||
candidate.connectionId.length > 0;
|
||||
const replacedPeerCandidate = this.pendingPeerRevision !== null;
|
||||
if (replacedPeerCandidate) {
|
||||
this.pendingPeerRevision = null;
|
||||
@@ -125,7 +143,7 @@ export class ViewerSfuRoute {
|
||||
}
|
||||
if (needsPeerCandidate && update.assignment.upstream.kind === "peer") {
|
||||
this.pendingPeerRevision = update.revision;
|
||||
this.events.preparePeer?.(update.assignment, update.revision);
|
||||
this.events.preparePeer?.(update.assignment, update.revision, candidate);
|
||||
} else if (
|
||||
update.assignment.upstream.kind !== "peer" &&
|
||||
!replacedPeerCandidate
|
||||
@@ -135,7 +153,11 @@ export class ViewerSfuRoute {
|
||||
if (this.pending?.revision !== update.revision) {
|
||||
this.clearPending();
|
||||
}
|
||||
if (update.assignment.upstream.kind !== "sfu") {
|
||||
if (
|
||||
!ownsUpstreamCandidate ||
|
||||
candidate.transport !== "sfu" ||
|
||||
update.assignment.upstream.kind !== "sfu"
|
||||
) {
|
||||
this.clearPending();
|
||||
}
|
||||
return result;
|
||||
@@ -155,21 +177,6 @@ export class ViewerSfuRoute {
|
||||
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) {
|
||||
@@ -253,7 +260,15 @@ export class ViewerSfuRoute {
|
||||
const token = this.route.token();
|
||||
const assignment = this.route.getPlannedAssignment();
|
||||
const phase = this.route.getPhase();
|
||||
if (!token || !phase || assignment?.upstream.kind !== "sfu") {
|
||||
const candidate = this.route.getPreparedCandidate();
|
||||
if (
|
||||
!token ||
|
||||
!phase ||
|
||||
assignment?.upstream.kind !== "sfu" ||
|
||||
(phase === "prepare" &&
|
||||
(candidate?.childPeerId !== this.viewerPeerId ||
|
||||
candidate.transport !== "sfu"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -555,15 +570,7 @@ export class ViewerSfuRoute {
|
||||
const changed = slot.mediaAvailable !== available;
|
||||
slot.mediaAvailable = available;
|
||||
this.events.onSfuVideoAvailability?.(available);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
if (!available) {
|
||||
this.events.send({
|
||||
type: "route-media-unavailable",
|
||||
revision: slot.revision,
|
||||
});
|
||||
}
|
||||
if (!changed || this.paused) return;
|
||||
}
|
||||
|
||||
private commitMedia(token: RouteOperationToken): void {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type CreateRoomResponse,
|
||||
type IceConfig,
|
||||
type ParticipantPresenceEntry,
|
||||
type PreparedRouteCandidate,
|
||||
type ServerMessage,
|
||||
type ViewerAccessPolicy,
|
||||
} from "../../shared/protocol";
|
||||
@@ -272,6 +273,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const retiredConnectionsRef = useRef(new Map<string, string>());
|
||||
const hostProvisionalChildRef = useRef<HostProvisionalChild | null>(null);
|
||||
const activeHostChildPeerIdsRef = useRef<string[]>([]);
|
||||
const endpointMediaCopyCapacityRef = useRef(MAX_ENDPOINT_MEDIA_CHILDREN);
|
||||
const hostPeerIdRef = useRef<string | null>(null);
|
||||
const viewerQualityEvidenceRef = useRef(
|
||||
new Map<string, ViewerQualityEvidencePresentation>(),
|
||||
@@ -411,6 +413,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
hostProvisionalChildRef.current?.discard();
|
||||
hostProvisionalChildRef.current = null;
|
||||
activeHostChildPeerIdsRef.current = [];
|
||||
endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN;
|
||||
hostPeerIdRef.current = null;
|
||||
viewerQualityEvidenceTimersRef.current.forEach((timer) =>
|
||||
window.clearTimeout(timer),
|
||||
@@ -526,6 +529,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
hostProvisionalChildRef.current?.discard();
|
||||
hostProvisionalChildRef.current = null;
|
||||
activeHostChildPeerIdsRef.current = [];
|
||||
endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN;
|
||||
hostPeerIdRef.current = null;
|
||||
void hostSfuRouteRef.current?.disconnect();
|
||||
hostSfuRouteRef.current = null;
|
||||
@@ -909,6 +913,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
function prepareHostChild(
|
||||
revision: number,
|
||||
assignment: HostRouteAssignment,
|
||||
candidate: PreparedRouteCandidate,
|
||||
generation: number,
|
||||
): boolean {
|
||||
const stream = streamRef.current;
|
||||
@@ -950,8 +955,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return hostProvisionalChildRef.current.prepare({
|
||||
revision,
|
||||
assignment,
|
||||
candidate,
|
||||
activeChildPeerIds: activeHostChildPeerIdsRef.current,
|
||||
maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
maxMediaEdges: endpointMediaCopyCapacityRef.current,
|
||||
iceConfig,
|
||||
stream,
|
||||
profile: qualitySettingsRef.current,
|
||||
@@ -969,7 +975,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
revision,
|
||||
assignment,
|
||||
activeChildPeerIds,
|
||||
maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
maxMediaEdges: endpointMediaCopyCapacityRef.current,
|
||||
}) ?? { kind: "ordinary" as const };
|
||||
if (activation.kind === "promote") {
|
||||
hostProvisionalChildRef.current = null;
|
||||
@@ -1106,7 +1112,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
reconcileBoundedMediaChildren(
|
||||
peersRef.current.keys(),
|
||||
childPeerIds,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
endpointMediaCopyCapacityRef.current,
|
||||
removePeer,
|
||||
(peerId) => {
|
||||
void startPeer(peerId, generation).catch((error: unknown) => {
|
||||
@@ -1129,6 +1135,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
discardPreparedHostChild();
|
||||
hostPeerIdRef.current = message.peerId;
|
||||
endpointMediaCopyCapacityRef.current = message.endpointMediaCopyCapacity;
|
||||
setViewerAccessUpdating(false);
|
||||
clearAllViewerQualityEvidence();
|
||||
setSfuStandbyUrl(
|
||||
@@ -1193,20 +1200,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const iceConfig = iceConfigRef.current;
|
||||
const activeStream = streamRef.current;
|
||||
if (
|
||||
peerAssistedRef.current &&
|
||||
message.parentPeerId === hostPeerIdRef.current &&
|
||||
iceConfig &&
|
||||
activeStream
|
||||
) {
|
||||
hostProvisionalChildRef.current?.prepareSelectedTurn(message, {
|
||||
iceConfig,
|
||||
stream: activeStream,
|
||||
profile: qualitySettingsRef.current,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "viewer-access-updated") {
|
||||
@@ -1249,8 +1242,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssistedRef.current) {
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
const accepted = route.accept(message);
|
||||
if (accepted === "stale") return;
|
||||
if (message.phase === "prepare") {
|
||||
prepareHostChild(message.revision, message.assignment, generation);
|
||||
if (message.candidate.transport === "direct") {
|
||||
prepareHostChild(
|
||||
message.revision,
|
||||
message.assignment,
|
||||
message.candidate,
|
||||
generation,
|
||||
);
|
||||
} else {
|
||||
discardPreparedHostChild();
|
||||
}
|
||||
} else {
|
||||
activatePreparedHostChild(message.revision, message.assignment);
|
||||
}
|
||||
@@ -1261,10 +1266,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
activeRouteRevisionRef.current = message.revision;
|
||||
clearAllViewerQualityEvidence();
|
||||
}
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.acceptAndWait(message)
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
showHostSfuQualityWarning(route, generation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { clearViewerGrant, getStableClientId } from "../lib/session";
|
||||
import { SignalingClient } from "../lib/signaling";
|
||||
import { labelParticipantSnapshot } from "../lib/viewer-presence";
|
||||
import { downloadDiagnosticReport, type DiagnosticConnectionInput } from "../lib/diagnostic-export";
|
||||
import { DecodedFrameStallDetector } from "../media/decoded-frame-stall";
|
||||
import type { QualitySettings } from "../media/quality";
|
||||
import {
|
||||
ParentEdgeQualityEvidenceReporter,
|
||||
@@ -105,7 +106,8 @@ interface PendingPeerRoute {
|
||||
peer: ViewerPeer | null;
|
||||
readySent: boolean;
|
||||
selectedTurn: SelectedEdgeTurn | null;
|
||||
candidateConnectionId: string | null;
|
||||
candidateConnectionId: string;
|
||||
transport: "direct" | "selected-turn";
|
||||
stream: MediaStream | null;
|
||||
snapshot: PeerSnapshot | null;
|
||||
}
|
||||
@@ -254,9 +256,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
let viewerAuthenticated = false;
|
||||
let currentIceConfig: IceConfig | null = null;
|
||||
let currentHostOnline = false;
|
||||
let currentHostPaused = false;
|
||||
let peerAssisted = false;
|
||||
let currentPeerId: string | null = null;
|
||||
let currentRouteRevision = 0;
|
||||
let endpointMediaCopyCapacity = MAX_ENDPOINT_MEDIA_CHILDREN;
|
||||
let viewerAuthorizationGeneration: string | null = null;
|
||||
let currentQualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS;
|
||||
let currentAssignment: MediaAssignment = {
|
||||
@@ -270,6 +274,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
Extract<ServerMessage, { type: "signal" }>
|
||||
> = [];
|
||||
let pendingPeer: PendingPeerRoute | null = null;
|
||||
const decodedFrameStall = new DecodedFrameStallDetector();
|
||||
const messageAuthority = new ViewerMessageAuthority();
|
||||
let sfuStandbyPrewarmer: SfuStandbyPrewarmer | null = null;
|
||||
let relayChildEvidenceCurrent: ViewerQualityEvidencePresentation | null =
|
||||
@@ -448,6 +453,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
},
|
||||
},
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
viewerRelay.setChildren(currentAssignment.childPeerIds);
|
||||
return viewerRelay;
|
||||
@@ -486,6 +492,37 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
probe?.peer?.dispose();
|
||||
}
|
||||
|
||||
function observeActiveDecodedFrames(
|
||||
route: "peer" | "sfu",
|
||||
identity: string,
|
||||
framesDecodedDelta: number | null,
|
||||
connectionId?: string,
|
||||
): void {
|
||||
if (
|
||||
!peerAssisted ||
|
||||
currentHostPaused ||
|
||||
!decodedFrameStall.observe(
|
||||
`${route}:${currentRouteRevision}:${identity}`,
|
||||
framesDecodedDelta,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (route === "peer" && connectionId) {
|
||||
signal.send({
|
||||
type: "route-failed",
|
||||
revision: currentRouteRevision,
|
||||
phase: "active",
|
||||
connectionId,
|
||||
});
|
||||
} else if (route === "sfu") {
|
||||
signal.send({
|
||||
type: "route-media-unavailable",
|
||||
revision: currentRouteRevision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pendingPeerHasDecodedFrame(
|
||||
probe: PendingPeerRoute,
|
||||
): probe is PendingPeerRoute & { peer: ViewerPeer; snapshot: PeerSnapshot } {
|
||||
@@ -515,7 +552,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
return viewerSfuRoute;
|
||||
}
|
||||
let route: ViewerSfuRoute;
|
||||
route = new ViewerSfuRoute({
|
||||
route = new ViewerSfuRoute(currentPeerId!, {
|
||||
activatePeer: async (assignment, revision) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
@@ -565,19 +602,26 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
await drainPreparedParentSignals(assignment.upstream.peerId);
|
||||
}
|
||||
},
|
||||
preparePeer: (assignment, revision) => {
|
||||
preparePeer: (assignment, revision, candidate) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
discardPendingPeer();
|
||||
if (revision !== undefined && assignment?.upstream.kind === "peer") {
|
||||
if (
|
||||
revision !== undefined &&
|
||||
candidate &&
|
||||
candidate.childPeerId === currentPeerId &&
|
||||
candidate.transport !== "sfu" &&
|
||||
assignment?.upstream.kind === "peer"
|
||||
) {
|
||||
pendingPeer = {
|
||||
revision,
|
||||
parentPeerId: assignment.upstream.peerId,
|
||||
peer: null,
|
||||
readySent: false,
|
||||
selectedTurn: null,
|
||||
candidateConnectionId: null,
|
||||
candidateConnectionId: candidate.connectionId,
|
||||
transport: candidate.transport,
|
||||
stream: null,
|
||||
snapshot: null,
|
||||
};
|
||||
@@ -588,12 +632,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
: null,
|
||||
);
|
||||
},
|
||||
prepareChild: (childPeerIds, revision) => {
|
||||
prepareChild: (candidate, childPeerIds, revision) => {
|
||||
if (!active || viewerSfuRoute !== route) {
|
||||
return;
|
||||
}
|
||||
if (childPeerIds && revision !== undefined) {
|
||||
ensureViewerRelay()?.prepareChild(revision, childPeerIds);
|
||||
if (candidate && childPeerIds && revision !== undefined) {
|
||||
ensureViewerRelay()?.prepareChild(revision, candidate, childPeerIds);
|
||||
} else {
|
||||
viewerRelay?.discardPreparedChild();
|
||||
}
|
||||
@@ -618,12 +662,19 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
parentPeerId: currentAssignment.parentPeerId,
|
||||
childPeerIds: [...childPeerIds],
|
||||
},
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
reconcileRelayChildren(previousChildPeerIds, revision);
|
||||
},
|
||||
onSfuUpdate: (metrics) => {
|
||||
if (active && viewerSfuRoute === route) {
|
||||
if (metrics) {
|
||||
observeActiveDecodedFrames(
|
||||
"sfu",
|
||||
String(currentRouteRevision),
|
||||
metrics.intervalFramesDecoded,
|
||||
);
|
||||
}
|
||||
setSfuUpstream((current) =>
|
||||
metrics && current ? { ...current, metrics } : null,
|
||||
);
|
||||
@@ -640,8 +691,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
if (!active || viewerSfuRoute !== route || available) {
|
||||
return;
|
||||
}
|
||||
setSfuUpstream(null);
|
||||
viewerRelay?.stop();
|
||||
setSfuUpstream((current) =>
|
||||
current ? { ...current, connectionState: "reconnecting" } : current,
|
||||
);
|
||||
setStatusText(
|
||||
currentHostOnline ? "正在恢复连接" : "等待开始分享",
|
||||
);
|
||||
@@ -653,7 +705,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const previousChildPeerIds = currentAssignment.childPeerIds;
|
||||
currentAssignment = limitMediaAssignment(
|
||||
{ parentPeerId: null, childPeerIds: assignment.childPeerIds },
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
reconcileRelayChildren(previousChildPeerIds);
|
||||
const relay = ensureViewerRelay();
|
||||
@@ -735,7 +787,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
function applyMediaAssignment(assignment: MediaAssignment, preserveUpstream = false): void {
|
||||
const nextAssignment = limitMediaAssignment(
|
||||
assignment,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
const previousParentId = currentAssignment.parentPeerId;
|
||||
const previousChildPeerIds = currentAssignment.childPeerIds;
|
||||
@@ -772,10 +824,16 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
},
|
||||
onUpdate: (snapshot) => {
|
||||
if (pendingPeer === probe) {
|
||||
if (snapshot.connectionId !== probe.candidateConnectionId) return;
|
||||
probe.snapshot = snapshot;
|
||||
probe.candidateConnectionId = snapshot.connectionId;
|
||||
provePendingPeer();
|
||||
} else if (active && peerRef.current === peer) {
|
||||
observeActiveDecodedFrames(
|
||||
"peer",
|
||||
`${probe.parentPeerId}:${snapshot.connectionId}`,
|
||||
snapshot.metrics.intervalFramesDecoded,
|
||||
snapshot.connectionId,
|
||||
);
|
||||
qualityEvidenceReporter.offer(snapshot, currentRouteRevision);
|
||||
setPeerSnapshot(snapshot);
|
||||
setStatusText(
|
||||
@@ -793,7 +851,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
phase: "prepare",
|
||||
connectionId,
|
||||
});
|
||||
probe.candidateConnectionId = connectionId;
|
||||
probe.peer = null;
|
||||
probe.stream = null;
|
||||
probe.snapshot = null;
|
||||
@@ -806,7 +863,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
: true;
|
||||
},
|
||||
},
|
||||
probe.selectedTurn !== null,
|
||||
probe.transport === "selected-turn",
|
||||
);
|
||||
probe.peer = peer;
|
||||
return peer;
|
||||
@@ -846,6 +903,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
},
|
||||
onUpdate: (snapshot) => {
|
||||
if (active) {
|
||||
observeActiveDecodedFrames(
|
||||
"peer",
|
||||
`${snapshot.peerId}:${snapshot.connectionId}`,
|
||||
snapshot.metrics.intervalFramesDecoded,
|
||||
snapshot.connectionId,
|
||||
);
|
||||
qualityEvidenceReporter.offer(snapshot, currentRouteRevision);
|
||||
if (
|
||||
!currentHostOnline &&
|
||||
@@ -890,6 +953,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
setViewerPasswordError(null);
|
||||
clearRelayChildEvidence();
|
||||
currentPeerId = message.peerId;
|
||||
endpointMediaCopyCapacity = message.endpointMediaCopyCapacity;
|
||||
viewerAuthorizationGeneration =
|
||||
message.viewerAuthorizationGeneration;
|
||||
setSfuStandbyUrl(
|
||||
@@ -930,6 +994,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
currentHostOnline = message.hostOnline;
|
||||
setHostOnline(message.hostOnline);
|
||||
const sharingPaused = message.hostPaused ?? false;
|
||||
currentHostPaused = sharingPaused;
|
||||
decodedFrameStall.setPaused(sharingPaused);
|
||||
setHostPaused(sharingPaused);
|
||||
if (nextPeerAssisted && "qualitySettings" in message) {
|
||||
currentQualitySettings = message.qualitySettings;
|
||||
@@ -997,54 +1063,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
let probe = pendingPeer;
|
||||
if (
|
||||
!viewerSfuRoute?.beginSelectedPeerCandidate(
|
||||
message.revision,
|
||||
message.parentPeerId,
|
||||
)
|
||||
) {
|
||||
const probe = pendingPeer;
|
||||
if (probe?.selectedTurn?.newConnectionId === message.newConnectionId) {
|
||||
return;
|
||||
}
|
||||
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 ||
|
||||
probe?.revision !== message.revision ||
|
||||
probe.parentPeerId !== message.parentPeerId ||
|
||||
probe.transport !== "selected-turn" ||
|
||||
probe.candidateConnectionId !== message.newConnectionId ||
|
||||
Date.parse(message.expiresAt) <= Date.now() ||
|
||||
oldConnectionId !== message.oldConnectionId
|
||||
probe.peer !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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") {
|
||||
@@ -1094,6 +1128,13 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
if (message.type === "signal") {
|
||||
if (peerAssisted && pendingPeer?.parentPeerId === message.fromPeerId) {
|
||||
if (
|
||||
message.payload.connectionId === pendingPeer.candidateConnectionId &&
|
||||
pendingPeer.transport === "selected-turn" &&
|
||||
!pendingPeer.selectedTurn
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (currentAssignment.parentPeerId !== message.fromPeerId) {
|
||||
await ensurePendingPeerRoute()?.acceptSignal(
|
||||
message.fromPeerId,
|
||||
@@ -1104,8 +1145,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const activeIdentity = peerRef.current?.getConnectionIdentity();
|
||||
const owner = exactPeerSignalOwner(
|
||||
message.payload.connectionId,
|
||||
pendingPeer.selectedTurn?.newConnectionId ??
|
||||
pendingPeer.candidateConnectionId,
|
||||
pendingPeer.candidateConnectionId,
|
||||
activeIdentity?.parentPeerId === message.fromPeerId
|
||||
? activeIdentity.connectionId
|
||||
: null,
|
||||
@@ -1171,6 +1211,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
if (message.type === "host-status") {
|
||||
currentHostOnline = message.online;
|
||||
currentHostPaused = message.paused;
|
||||
decodedFrameStall.setPaused(message.paused);
|
||||
setHostOnline(message.online);
|
||||
setHostPaused(message.paused);
|
||||
viewerSfuRoute?.setPaused(message.paused);
|
||||
@@ -1189,6 +1231,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
setSfuStandbyUrl(null);
|
||||
setAssignedRoute(null);
|
||||
currentHostOnline = false;
|
||||
currentHostPaused = false;
|
||||
decodedFrameStall.reset();
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
clearHostPresence();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
IceConfig,
|
||||
PreparedRouteCandidate,
|
||||
ServerMessage,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
@@ -22,6 +23,7 @@ type SelectedEdgeTurn = Extract<
|
||||
interface PreparedChild {
|
||||
revision: number;
|
||||
childPeerId: string;
|
||||
candidate: PreparedRouteCandidate;
|
||||
peer: HostPeer;
|
||||
failed: boolean;
|
||||
replacesConnectionId: string | null;
|
||||
@@ -34,6 +36,7 @@ export class ViewerRelay {
|
||||
private readonly snapshots = new Map<string, PeerSnapshot>();
|
||||
private preparedChild: PreparedChild | null = null;
|
||||
private preparedRevision: number | null = null;
|
||||
private preparedCandidate: PreparedRouteCandidate | null = null;
|
||||
private plannedChildPeerIds: string[] = [];
|
||||
private syncQueue = Promise.resolve();
|
||||
private disposed = false;
|
||||
@@ -42,6 +45,7 @@ export class ViewerRelay {
|
||||
private iceConfig: IceConfig,
|
||||
private desiredProfile: QualityProfile,
|
||||
private readonly events: ViewerRelayEvents,
|
||||
private readonly maxMediaEdges = MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
) {}
|
||||
|
||||
getSnapshot(childPeerId?: string): PeerSnapshot | null {
|
||||
@@ -60,38 +64,37 @@ export class ViewerRelay {
|
||||
routeRevision: number,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const stream = this.stream;
|
||||
if (
|
||||
this.disposed ||
|
||||
!stream ||
|
||||
message.parentPeerId !== parentPeerId ||
|
||||
message.revision !== routeRevision ||
|
||||
Date.parse(message.expiresAt) <= now ||
|
||||
this.preparedRevision !== routeRevision ||
|
||||
!this.plannedChildPeerIds.includes(message.viewerPeerId)
|
||||
this.preparedCandidate?.transport !== "selected-turn" ||
|
||||
this.preparedCandidate.childPeerId !== message.viewerPeerId ||
|
||||
this.preparedCandidate.connectionId !== message.newConnectionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const prepared = this.preparedChild;
|
||||
if (prepared && prepared.childPeerId !== message.viewerPeerId) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const stream = this.stream;
|
||||
if (!stream) return false;
|
||||
this.discardPreparedPeer();
|
||||
this.startPreparedChild(message.revision, message.viewerPeerId, stream, message);
|
||||
this.startPreparedChild(
|
||||
message.revision,
|
||||
this.preparedCandidate,
|
||||
stream,
|
||||
message,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
prepareChild(
|
||||
revision: number,
|
||||
candidate: PreparedRouteCandidate,
|
||||
plannedChildPeerIds: readonly string[],
|
||||
): boolean {
|
||||
const stream = this.stream;
|
||||
@@ -99,10 +102,12 @@ export class ViewerRelay {
|
||||
if (
|
||||
this.disposed ||
|
||||
!stream ||
|
||||
candidate.transport === "sfu" ||
|
||||
!planned.includes(candidate.childPeerId) ||
|
||||
planned.length !== plannedChildPeerIds.length ||
|
||||
planned.length < this.childPeerIds.length ||
|
||||
planned.length > this.childPeerIds.length + 1 ||
|
||||
planned.length > MAX_ENDPOINT_MEDIA_CHILDREN ||
|
||||
planned.length > this.maxMediaEdges ||
|
||||
this.childPeerIds.some((peerId) => !planned.includes(peerId))
|
||||
) {
|
||||
this.discardPreparedChild();
|
||||
@@ -110,6 +115,7 @@ export class ViewerRelay {
|
||||
}
|
||||
if (
|
||||
this.preparedRevision === revision &&
|
||||
sameCandidate(this.preparedCandidate, candidate) &&
|
||||
samePeerIds(this.plannedChildPeerIds, planned)
|
||||
) {
|
||||
return this.preparedChild ? !this.preparedChild.failed : true;
|
||||
@@ -117,14 +123,11 @@ export class ViewerRelay {
|
||||
|
||||
this.discardPreparedChild();
|
||||
this.preparedRevision = revision;
|
||||
this.preparedCandidate = { ...candidate };
|
||||
this.plannedChildPeerIds = planned;
|
||||
const childPeerId = planned.find(
|
||||
(peerId) => !this.childPeerIds.includes(peerId),
|
||||
);
|
||||
if (!childPeerId) {
|
||||
return true;
|
||||
if (candidate.transport === "direct") {
|
||||
this.startPreparedChild(revision, candidate, stream);
|
||||
}
|
||||
this.startPreparedChild(revision, childPeerId, stream);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -142,11 +145,13 @@ export class ViewerRelay {
|
||||
if (
|
||||
prepared?.revision === revision &&
|
||||
!prepared.failed &&
|
||||
prepared.candidate.connectionId === prepared.peer.connectionId &&
|
||||
childPeerIds.includes(prepared.childPeerId) &&
|
||||
(extendsActive || replacesActive)
|
||||
) {
|
||||
this.preparedChild = null;
|
||||
this.preparedRevision = null;
|
||||
this.preparedCandidate = null;
|
||||
this.plannedChildPeerIds = [];
|
||||
this.peers.set(prepared.childPeerId, prepared.peer);
|
||||
replacedPeer?.dispose();
|
||||
@@ -160,6 +165,7 @@ export class ViewerRelay {
|
||||
|
||||
discardPreparedChild(): void {
|
||||
this.preparedRevision = null;
|
||||
this.preparedCandidate = null;
|
||||
this.plannedChildPeerIds = [];
|
||||
this.discardPreparedPeer();
|
||||
}
|
||||
@@ -183,7 +189,7 @@ export class ViewerRelay {
|
||||
}
|
||||
const nextChildPeerIds = [...new Set(childPeerIds)].slice(
|
||||
0,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
this.maxMediaEdges,
|
||||
);
|
||||
for (const childPeerId of this.childPeerIds) {
|
||||
if (!nextChildPeerIds.includes(childPeerId)) {
|
||||
@@ -330,17 +336,24 @@ export class ViewerRelay {
|
||||
|
||||
private startPreparedChild(
|
||||
revision: number,
|
||||
childPeerId: string,
|
||||
candidate: PreparedRouteCandidate,
|
||||
stream: MediaStream,
|
||||
selectedTurn?: SelectedEdgeTurn,
|
||||
): void {
|
||||
const peer = this.createPeer(childPeerId, stream, selectedTurn);
|
||||
const peer = this.createPeer(
|
||||
candidate.childPeerId,
|
||||
stream,
|
||||
candidate.connectionId,
|
||||
selectedTurn,
|
||||
);
|
||||
this.preparedChild = {
|
||||
revision,
|
||||
childPeerId,
|
||||
childPeerId: candidate.childPeerId,
|
||||
candidate: { ...candidate },
|
||||
peer,
|
||||
failed: false,
|
||||
replacesConnectionId: selectedTurn?.oldConnectionId ?? null,
|
||||
replacesConnectionId:
|
||||
this.peers.get(candidate.childPeerId)?.connectionId ?? null,
|
||||
};
|
||||
void peer
|
||||
.start()
|
||||
@@ -470,6 +483,7 @@ export class ViewerRelay {
|
||||
private createPeer(
|
||||
childPeerId: string,
|
||||
stream: MediaStream,
|
||||
connectionId?: string,
|
||||
selectedTurn?: SelectedEdgeTurn,
|
||||
): HostPeer {
|
||||
let peer: HostPeer;
|
||||
@@ -511,7 +525,7 @@ export class ViewerRelay {
|
||||
},
|
||||
},
|
||||
selectedTurn !== undefined,
|
||||
selectedTurn?.newConnectionId,
|
||||
connectionId,
|
||||
);
|
||||
return peer;
|
||||
}
|
||||
@@ -541,6 +555,17 @@ export class ViewerRelay {
|
||||
|
||||
}
|
||||
|
||||
function sameCandidate(
|
||||
left: PreparedRouteCandidate | null,
|
||||
right: PreparedRouteCandidate,
|
||||
): boolean {
|
||||
return (
|
||||
left?.childPeerId === right.childPeerId &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.transport === right.transport
|
||||
);
|
||||
}
|
||||
|
||||
function samePeerIds(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
|
||||
+1338
-4461
File diff suppressed because it is too large.
Load diff
@@ -1,461 +0,0 @@
|
||||
import {
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
participantRouteAssignmentSchema,
|
||||
sfuPublicationGenerationSchema,
|
||||
type MediaRoutePhase,
|
||||
type ParticipantRouteAssignment,
|
||||
} from "../shared/protocol.js";
|
||||
import {
|
||||
assertEndpointMediaCopyCapacity,
|
||||
assertEndpointMediaCopyCount,
|
||||
countEndpointMediaCopies,
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
|
||||
const MAX_PARTICIPANTS_PER_ROOM = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
|
||||
export interface RoomSfuRoute {
|
||||
publicationGeneration: string | null;
|
||||
rootPeerIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface RoomMediaRoute {
|
||||
revision: number;
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
sfu: RoomSfuRoute;
|
||||
}
|
||||
|
||||
export interface PendingRoomMediaRoute {
|
||||
route: RoomMediaRoute;
|
||||
expectedParticipantIds: ReadonlySet<string>;
|
||||
readyParticipantIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface MediaRouteControllerOptions {
|
||||
hostPeerId: string;
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
revision?: number;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
endpointMediaCopyCapacity?: number;
|
||||
}
|
||||
|
||||
export interface PrepareMediaRouteInput {
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
expectedParticipantIds: ReadonlySet<string>;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ReconcileBaselineInput {
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
}
|
||||
|
||||
interface StoredPendingRoute {
|
||||
route: RoomMediaRoute;
|
||||
expectedParticipantIds: Set<string>;
|
||||
readyParticipantIds: Set<string>;
|
||||
}
|
||||
|
||||
export class MediaRouteController {
|
||||
private activeRoute: RoomMediaRoute;
|
||||
private pendingRoute: StoredPendingRoute | undefined;
|
||||
private latestRevision: number;
|
||||
private readonly hostPeerId: string;
|
||||
private readonly endpointMediaCopyCapacity: number;
|
||||
|
||||
constructor(options: MediaRouteControllerOptions) {
|
||||
this.hostPeerId = options.hostPeerId;
|
||||
this.endpointMediaCopyCapacity =
|
||||
options.endpointMediaCopyCapacity ??
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY;
|
||||
assertEndpointMediaCopyCapacity(this.endpointMediaCopyCapacity);
|
||||
const revision = options.revision ?? 0;
|
||||
assertRevision(revision);
|
||||
this.activeRoute = createRoute(
|
||||
revision,
|
||||
options.assignments,
|
||||
options.sfuPublicationGeneration ?? null,
|
||||
options.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
this.latestRevision = revision;
|
||||
}
|
||||
|
||||
getActiveRoute(): RoomMediaRoute {
|
||||
return cloneRoute(this.activeRoute);
|
||||
}
|
||||
|
||||
getPendingRoute(): PendingRoomMediaRoute | undefined {
|
||||
const pending = this.pendingRoute;
|
||||
return pending
|
||||
? {
|
||||
route: cloneRoute(pending.route),
|
||||
expectedParticipantIds: new Set(pending.expectedParticipantIds),
|
||||
readyParticipantIds: new Set(pending.readyParticipantIds),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
reconcileBaseline(input: ReconcileBaselineInput): number | undefined {
|
||||
if (this.pendingRoute) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const revision = this.nextRevision();
|
||||
const route = createRoute(
|
||||
revision,
|
||||
input.assignments,
|
||||
input.sfuPublicationGeneration === undefined
|
||||
? this.activeRoute.sfu.publicationGeneration
|
||||
: input.sfuPublicationGeneration,
|
||||
input.sfuRootPeerIds ?? this.activeRoute.sfu.rootPeerIds,
|
||||
this.hostPeerId,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
if (hasSameTopology(route, this.activeRoute)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.activeRoute = route;
|
||||
this.latestRevision = revision;
|
||||
return revision;
|
||||
}
|
||||
|
||||
prepare(input: PrepareMediaRouteInput): number | undefined {
|
||||
if (this.pendingRoute) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const revision = this.nextRevision();
|
||||
const route = createRoute(
|
||||
revision,
|
||||
input.assignments,
|
||||
input.sfuPublicationGeneration ?? null,
|
||||
input.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
const expectedParticipantIds = new Set(input.expectedParticipantIds);
|
||||
if (expectedParticipantIds.size > MAX_PARTICIPANTS_PER_ROOM) {
|
||||
throw new Error("Too many expected media route participants");
|
||||
}
|
||||
for (const peerId of expectedParticipantIds) {
|
||||
if (!route.assignments.has(peerId)) {
|
||||
throw new Error(`Expected participant ${peerId} has no route assignment`);
|
||||
}
|
||||
}
|
||||
|
||||
this.latestRevision = revision;
|
||||
this.pendingRoute = {
|
||||
route,
|
||||
expectedParticipantIds,
|
||||
readyParticipantIds: new Set(),
|
||||
};
|
||||
return revision;
|
||||
}
|
||||
|
||||
ready(peerId: string, revision: number, phase: MediaRoutePhase): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (
|
||||
phase !== "prepare" ||
|
||||
!pending ||
|
||||
pending.route.revision !== revision ||
|
||||
!pending.expectedParticipantIds.has(peerId) ||
|
||||
pending.readyParticipantIds.has(peerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pending.readyParticipantIds.add(peerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
commit(revision: number): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (!pending || pending.route.revision !== revision) {
|
||||
return false;
|
||||
}
|
||||
for (const peerId of pending.expectedParticipantIds) {
|
||||
if (!pending.readyParticipantIds.has(peerId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeRoute = pending.route;
|
||||
this.pendingRoute = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
abort(revision: number): boolean {
|
||||
const pending = this.pendingRoute;
|
||||
if (!pending || pending.route.revision !== revision) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rollbackRevision = this.nextRevision();
|
||||
this.activeRoute = cloneRoute(this.activeRoute, rollbackRevision);
|
||||
this.latestRevision = rollbackRevision;
|
||||
this.pendingRoute = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
hostActiveMediaEdges(): number {
|
||||
const edgeCount = hostMediaEdges(this.activeRoute, this.hostPeerId);
|
||||
assertEndpointMediaCopyCount(
|
||||
edgeCount,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
return edgeCount;
|
||||
}
|
||||
|
||||
private nextRevision(): number {
|
||||
if (this.latestRevision >= MAX_MEDIA_ROUTE_REVISION) {
|
||||
throw new Error("Media route revision space exhausted");
|
||||
}
|
||||
return this.latestRevision + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createRoute(
|
||||
revision: number,
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
|
||||
publicationGeneration: string | null,
|
||||
rootPeerIds: readonly string[],
|
||||
hostPeerId: string,
|
||||
endpointMediaCopyCapacity: number,
|
||||
): RoomMediaRoute {
|
||||
assertRevision(revision);
|
||||
if (
|
||||
assignments.size === 0 ||
|
||||
assignments.size > MAX_PARTICIPANTS_PER_ROOM
|
||||
) {
|
||||
throw new Error("Media route participant count is outside room bounds");
|
||||
}
|
||||
if (publicationGeneration !== null) {
|
||||
sfuPublicationGenerationSchema.parse(publicationGeneration);
|
||||
}
|
||||
|
||||
const clonedAssignments = new Map<string, ParticipantRouteAssignment>();
|
||||
for (const [peerId, assignment] of assignments) {
|
||||
const parsed = participantRouteAssignmentSchema.parse(assignment);
|
||||
clonedAssignments.set(peerId, cloneAssignment(parsed));
|
||||
}
|
||||
const route: RoomMediaRoute = {
|
||||
revision,
|
||||
assignments: clonedAssignments,
|
||||
sfu: {
|
||||
publicationGeneration,
|
||||
rootPeerIds: [...rootPeerIds],
|
||||
},
|
||||
};
|
||||
assertRouteInvariants(
|
||||
route,
|
||||
hostPeerId,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
return route;
|
||||
}
|
||||
|
||||
function assertRouteInvariants(
|
||||
route: RoomMediaRoute,
|
||||
hostPeerId: string,
|
||||
endpointMediaCopyCapacity: number,
|
||||
): void {
|
||||
const hostAssignment = route.assignments.get(hostPeerId);
|
||||
if (!hostAssignment) {
|
||||
throw new Error("Media route is missing its host assignment");
|
||||
}
|
||||
if (hostAssignment.upstream.kind !== "none") {
|
||||
throw new Error("The host cannot have a media upstream");
|
||||
}
|
||||
if (
|
||||
hostAssignment.sfuPublicationGeneration !==
|
||||
route.sfu.publicationGeneration
|
||||
) {
|
||||
throw new Error("Host and room SFU publication generations differ");
|
||||
}
|
||||
|
||||
const rootPeerIds = new Set(route.sfu.rootPeerIds);
|
||||
if (rootPeerIds.size !== route.sfu.rootPeerIds.length) {
|
||||
throw new Error("SFU root participants must be unique");
|
||||
}
|
||||
if (
|
||||
(route.sfu.publicationGeneration === null) !== (rootPeerIds.size === 0)
|
||||
) {
|
||||
throw new Error("SFU roots and publication generation must be active together");
|
||||
}
|
||||
|
||||
for (const [peerId, assignment] of route.assignments) {
|
||||
assertEndpointMediaCopyCount(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: assignment.childPeerIds,
|
||||
publicationGeneration: assignment.sfuPublicationGeneration,
|
||||
}),
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
if (
|
||||
peerId !== hostPeerId &&
|
||||
assignment.sfuPublicationGeneration !== null
|
||||
) {
|
||||
throw new Error("Only the host may own an SFU publication generation");
|
||||
}
|
||||
if (assignment.childPeerIds.includes(peerId)) {
|
||||
throw new Error("A participant cannot be its own media child");
|
||||
}
|
||||
|
||||
for (const childPeerId of assignment.childPeerIds) {
|
||||
const childAssignment = route.assignments.get(childPeerId);
|
||||
if (!childAssignment) {
|
||||
throw new Error(`Media child ${childPeerId} has no assignment`);
|
||||
}
|
||||
if (
|
||||
childAssignment.upstream.kind !== "peer" ||
|
||||
childAssignment.upstream.peerId !== peerId
|
||||
) {
|
||||
throw new Error("Peer media edges must be reciprocal");
|
||||
}
|
||||
}
|
||||
|
||||
if (assignment.upstream.kind === "peer") {
|
||||
const parentAssignment = route.assignments.get(
|
||||
assignment.upstream.peerId,
|
||||
);
|
||||
if (!parentAssignment?.childPeerIds.includes(peerId)) {
|
||||
throw new Error("Peer media edges must be reciprocal");
|
||||
}
|
||||
}
|
||||
if (assignment.upstream.kind === "sfu" && !rootPeerIds.has(peerId)) {
|
||||
throw new Error("Every SFU upstream must belong to the root allowlist");
|
||||
}
|
||||
if (
|
||||
assignment.upstream.kind === "sfu" &&
|
||||
assignment.childPeerIds.length > 0
|
||||
) {
|
||||
throw new Error("SFU-fed viewers require outbound relay proof");
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootPeerId of rootPeerIds) {
|
||||
const rootAssignment = route.assignments.get(rootPeerId);
|
||||
if (!rootAssignment || rootAssignment.upstream.kind !== "sfu") {
|
||||
throw new Error("Every SFU root must have the SFU as its upstream");
|
||||
}
|
||||
}
|
||||
|
||||
assertAcyclicPeerEdges(route.assignments);
|
||||
assertEndpointMediaCopyCount(
|
||||
hostMediaEdges(route, hostPeerId),
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
}
|
||||
|
||||
function assertAcyclicPeerEdges(
|
||||
assignments: ReadonlyMap<string, ParticipantRouteAssignment>,
|
||||
): void {
|
||||
for (const startPeerId of assignments.keys()) {
|
||||
const ancestors = new Set([startPeerId]);
|
||||
let assignment = assignments.get(startPeerId);
|
||||
while (assignment?.upstream.kind === "peer") {
|
||||
const parentPeerId = assignment.upstream.peerId;
|
||||
if (ancestors.has(parentPeerId)) {
|
||||
throw new Error("Peer media route contains a cycle");
|
||||
}
|
||||
ancestors.add(parentPeerId);
|
||||
assignment = assignments.get(parentPeerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hostMediaEdges(route: RoomMediaRoute, hostPeerId: string): number {
|
||||
const hostAssignment = route.assignments.get(hostPeerId);
|
||||
if (!hostAssignment) {
|
||||
throw new Error("Media route is missing its host assignment");
|
||||
}
|
||||
return countEndpointMediaCopies({
|
||||
childPeerIds: hostAssignment.childPeerIds,
|
||||
publicationGeneration: route.sfu.publicationGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
function hasSameTopology(left: RoomMediaRoute, right: RoomMediaRoute): boolean {
|
||||
if (
|
||||
left.sfu.publicationGeneration !== right.sfu.publicationGeneration ||
|
||||
left.sfu.rootPeerIds.length !== right.sfu.rootPeerIds.length ||
|
||||
left.sfu.rootPeerIds.some(
|
||||
(peerId, index) => peerId !== right.sfu.rootPeerIds[index],
|
||||
) ||
|
||||
left.assignments.size !== right.assignments.size
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [peerId, leftAssignment] of left.assignments) {
|
||||
const rightAssignment = right.assignments.get(peerId);
|
||||
if (
|
||||
!rightAssignment ||
|
||||
leftAssignment.sfuPublicationGeneration !==
|
||||
rightAssignment.sfuPublicationGeneration ||
|
||||
leftAssignment.upstream.kind !== rightAssignment.upstream.kind ||
|
||||
(leftAssignment.upstream.kind === "peer" &&
|
||||
(rightAssignment.upstream.kind !== "peer" ||
|
||||
leftAssignment.upstream.peerId !==
|
||||
rightAssignment.upstream.peerId)) ||
|
||||
leftAssignment.childPeerIds.length !==
|
||||
rightAssignment.childPeerIds.length ||
|
||||
leftAssignment.childPeerIds.some(
|
||||
(childPeerId, index) =>
|
||||
childPeerId !== rightAssignment.childPeerIds[index],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cloneRoute(
|
||||
route: RoomMediaRoute,
|
||||
revision = route.revision,
|
||||
): RoomMediaRoute {
|
||||
return {
|
||||
revision,
|
||||
assignments: new Map(
|
||||
[...route.assignments].map(([peerId, assignment]) => [
|
||||
peerId,
|
||||
cloneAssignment(assignment),
|
||||
]),
|
||||
),
|
||||
sfu: {
|
||||
publicationGeneration: route.sfu.publicationGeneration,
|
||||
rootPeerIds: [...route.sfu.rootPeerIds],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneAssignment(
|
||||
assignment: ParticipantRouteAssignment,
|
||||
): ParticipantRouteAssignment {
|
||||
return {
|
||||
upstream:
|
||||
assignment.upstream.kind === "peer"
|
||||
? { kind: "peer", peerId: assignment.upstream.peerId }
|
||||
: { kind: assignment.upstream.kind },
|
||||
childPeerIds: [...assignment.childPeerIds],
|
||||
sfuPublicationGeneration: assignment.sfuPublicationGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
function assertRevision(revision: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(revision) ||
|
||||
revision < 0 ||
|
||||
revision > MAX_MEDIA_ROUTE_REVISION
|
||||
) {
|
||||
throw new Error("Media route revision is outside protocol bounds");
|
||||
}
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
import type {
|
||||
MediaAssignment,
|
||||
RelayDownstreamEdges,
|
||||
} from "../shared/protocol.js";
|
||||
import {
|
||||
assertEndpointMediaCopyCapacity,
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
|
||||
export interface MediaAssignmentChange {
|
||||
peerId: string;
|
||||
previousParentPeerId?: string | null;
|
||||
mediaAssignment: MediaAssignment;
|
||||
}
|
||||
|
||||
export interface RelayCapacityUpdateOptions {
|
||||
rescueUnassignedRelay?: boolean;
|
||||
}
|
||||
|
||||
export const MAX_PEER_RELAY_DEPTH = 4;
|
||||
|
||||
interface RelayViewer {
|
||||
order: number;
|
||||
parentPeerId: string | null;
|
||||
childPeerIds: string[];
|
||||
downstreamEdges: RelayDownstreamEdges;
|
||||
}
|
||||
|
||||
interface RelayRoom {
|
||||
hostPeerId?: string;
|
||||
hostChildPeerIds: string[];
|
||||
endpointMediaCopyCapacity: RelayDownstreamEdges;
|
||||
viewers: Map<string, RelayViewer>;
|
||||
nextOrder: number;
|
||||
}
|
||||
|
||||
export class PeerRelayTopology {
|
||||
private readonly rooms = new Map<string, RelayRoom>();
|
||||
private readonly endpointMediaCopyCapacity: RelayDownstreamEdges;
|
||||
|
||||
constructor(
|
||||
endpointMediaCopyCapacity: RelayDownstreamEdges =
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
) {
|
||||
assertEndpointMediaCopyCapacity(endpointMediaCopyCapacity);
|
||||
this.endpointMediaCopyCapacity = endpointMediaCopyCapacity;
|
||||
}
|
||||
|
||||
setHost(
|
||||
roomId: string,
|
||||
hostPeerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.room(roomId);
|
||||
const before = snapshot(room);
|
||||
const previousHostPeerId = room.hostPeerId;
|
||||
room.hostPeerId = hostPeerId;
|
||||
|
||||
if (previousHostPeerId !== hostPeerId) {
|
||||
for (const childPeerId of room.hostChildPeerIds) {
|
||||
const child = room.viewers.get(childPeerId);
|
||||
if (child) {
|
||||
child.parentPeerId = hostPeerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [peerId, viewer] of orderedViewers(room)) {
|
||||
if (viewer.parentPeerId === null) {
|
||||
this.assignViewer(room, peerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
addViewer(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.room(roomId);
|
||||
const before = snapshot(room);
|
||||
const existingViewer = room.viewers.get(peerId);
|
||||
if (!existingViewer) {
|
||||
room.viewers.set(peerId, {
|
||||
order: room.nextOrder,
|
||||
parentPeerId: null,
|
||||
childPeerIds: [],
|
||||
downstreamEdges: 0,
|
||||
});
|
||||
room.nextOrder += 1;
|
||||
} else {
|
||||
existingViewer.downstreamEdges = 0;
|
||||
}
|
||||
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
|
||||
if (viewer.parentPeerId === null) {
|
||||
this.assignViewer(room, candidatePeerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
setViewerRelayCapacity(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
downstreamEdges: RelayDownstreamEdges,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
options: RelayCapacityUpdateOptions = {},
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.rooms.get(roomId);
|
||||
const viewer = room?.viewers.get(peerId);
|
||||
if (!room || !viewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const before = snapshot(room);
|
||||
const boundedDownstreamEdges = Math.min(
|
||||
downstreamEdges,
|
||||
room.endpointMediaCopyCapacity,
|
||||
);
|
||||
const previousDownstreamEdges = viewer.downstreamEdges;
|
||||
viewer.downstreamEdges = boundedDownstreamEdges;
|
||||
for (const [candidatePeerId, candidate] of orderedViewers(room)) {
|
||||
if (candidate.parentPeerId === null) {
|
||||
this.assignViewer(room, candidatePeerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.rescueUnassignedRelay &&
|
||||
previousDownstreamEdges === 0 &&
|
||||
boundedDownstreamEdges > 0
|
||||
) {
|
||||
this.rescueUnassignedRelay(room, peerId, connectedPeerIds);
|
||||
}
|
||||
for (const [candidatePeerId, candidate] of orderedViewers(room)) {
|
||||
if (candidate.parentPeerId === null) {
|
||||
this.assignViewer(room, candidatePeerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
removeViewer(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): MediaAssignmentChange[] {
|
||||
const room = this.rooms.get(roomId);
|
||||
const removed = room?.viewers.get(peerId);
|
||||
if (!room || !removed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const before = snapshot(room);
|
||||
this.detach(room, peerId, removed.parentPeerId);
|
||||
const subtreeRootPeerIds = [...removed.childPeerIds];
|
||||
for (const subtreeRootPeerId of subtreeRootPeerIds) {
|
||||
const subtreeRoot = room.viewers.get(subtreeRootPeerId);
|
||||
if (subtreeRoot) {
|
||||
subtreeRoot.parentPeerId = null;
|
||||
}
|
||||
}
|
||||
room.viewers.delete(peerId);
|
||||
|
||||
for (const subtreeRootPeerId of subtreeRootPeerIds) {
|
||||
this.assignViewer(room, subtreeRootPeerId, connectedPeerIds);
|
||||
}
|
||||
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
|
||||
if (viewer.parentPeerId === null) {
|
||||
this.assignViewer(room, candidatePeerId, connectedPeerIds);
|
||||
}
|
||||
}
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
getAssignment(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
): MediaAssignment | undefined {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) {
|
||||
return undefined;
|
||||
}
|
||||
if (room.hostPeerId === peerId) {
|
||||
return {
|
||||
parentPeerId: null,
|
||||
childPeerIds: [...room.hostChildPeerIds],
|
||||
};
|
||||
}
|
||||
const viewer = room.viewers.get(peerId);
|
||||
return viewer
|
||||
? {
|
||||
parentPeerId: viewer.parentPeerId,
|
||||
childPeerIds: [...viewer.childPeerIds],
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
getAssignments(roomId: string): Map<string, MediaAssignment> {
|
||||
const room = this.rooms.get(roomId);
|
||||
return room ? snapshot(room) : new Map();
|
||||
}
|
||||
|
||||
getDownstreamCapacity(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
const room = this.rooms.get(roomId);
|
||||
return room ? downstreamCapacity(room, peerId) : 0;
|
||||
}
|
||||
|
||||
getAdvertisedDownstreamCapacity(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
const room = this.rooms.get(roomId);
|
||||
return room ? advertisedDownstreamCapacity(room, peerId) : 0;
|
||||
}
|
||||
|
||||
getHostPeerId(roomId: string): string | undefined {
|
||||
return this.rooms.get(roomId)?.hostPeerId;
|
||||
}
|
||||
|
||||
reassignViewer(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
excludedParentPeerIds: ReadonlySet<string>,
|
||||
maxDepth = MAX_PEER_RELAY_DEPTH,
|
||||
requiredParentPeerId?: string,
|
||||
): MediaAssignmentChange[] | undefined {
|
||||
const parentPeerId = this.findViewerReassignmentParent(
|
||||
roomId,
|
||||
peerId,
|
||||
connectedPeerIds,
|
||||
excludedParentPeerIds,
|
||||
maxDepth,
|
||||
);
|
||||
if (!parentPeerId || (requiredParentPeerId && parentPeerId !== requiredParentPeerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
const viewer = room?.viewers.get(peerId);
|
||||
if (!room || !viewer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const before = snapshot(room);
|
||||
this.detach(room, peerId, viewer.parentPeerId);
|
||||
viewer.parentPeerId = parentPeerId;
|
||||
const children = childPeerIds(room, parentPeerId);
|
||||
children.push(peerId);
|
||||
children.sort(
|
||||
(left, right) =>
|
||||
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
|
||||
);
|
||||
return changedAssignments(before, snapshot(room));
|
||||
}
|
||||
|
||||
findViewerReassignmentParent(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
excludedParentPeerIds: ReadonlySet<string>,
|
||||
maxDepth = MAX_PEER_RELAY_DEPTH,
|
||||
): string | undefined {
|
||||
const room = this.rooms.get(roomId);
|
||||
const viewer = room?.viewers.get(peerId);
|
||||
if (
|
||||
!room?.hostPeerId ||
|
||||
!viewer ||
|
||||
!connectedPeerIds.has(peerId) ||
|
||||
!Number.isSafeInteger(maxDepth) ||
|
||||
maxDepth < 1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subtreePeerIds = collectSubtreePeerIds(room, peerId);
|
||||
const subtreeHeight = maximumSubtreeDepth(room, peerId);
|
||||
if (maximumDepthOutsideSubtree(room, subtreePeerIds) > maxDepth) {
|
||||
return undefined;
|
||||
}
|
||||
const parentPeerId = findReassignmentParent(
|
||||
room,
|
||||
connectedPeerIds,
|
||||
excludedParentPeerIds,
|
||||
subtreePeerIds,
|
||||
maxDepth - subtreeHeight - 1,
|
||||
);
|
||||
return parentPeerId === viewer.parentPeerId ? undefined : parentPeerId;
|
||||
}
|
||||
|
||||
isParentOf(roomId: string, parentPeerId: string, childPeerId: string): boolean {
|
||||
return (
|
||||
this.rooms.get(roomId)?.viewers.get(childPeerId)?.parentPeerId ===
|
||||
parentPeerId
|
||||
);
|
||||
}
|
||||
|
||||
deleteRoom(roomId: string): void {
|
||||
this.rooms.delete(roomId);
|
||||
}
|
||||
|
||||
private room(roomId: string): RelayRoom {
|
||||
let room = this.rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = {
|
||||
hostChildPeerIds: [],
|
||||
endpointMediaCopyCapacity: this.endpointMediaCopyCapacity,
|
||||
viewers: new Map(),
|
||||
nextOrder: 0,
|
||||
};
|
||||
this.rooms.set(roomId, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private assignViewer(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): void {
|
||||
const viewer = room.viewers.get(peerId);
|
||||
if (
|
||||
!viewer ||
|
||||
viewer.parentPeerId !== null ||
|
||||
!connectedPeerIds.has(peerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parentPeerId = findShallowestAvailableParent(
|
||||
room,
|
||||
connectedPeerIds,
|
||||
MAX_PEER_RELAY_DEPTH - maximumSubtreeDepth(room, peerId) - 1,
|
||||
);
|
||||
if (!parentPeerId) {
|
||||
return;
|
||||
}
|
||||
viewer.parentPeerId = parentPeerId;
|
||||
const children = childPeerIds(room, parentPeerId);
|
||||
children.push(peerId);
|
||||
children.sort(
|
||||
(left, right) =>
|
||||
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
|
||||
);
|
||||
}
|
||||
|
||||
private detach(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
parentPeerId: string | null,
|
||||
): void {
|
||||
if (!parentPeerId) {
|
||||
return;
|
||||
}
|
||||
const children = childPeerIds(room, parentPeerId);
|
||||
const childIndex = children.indexOf(peerId);
|
||||
if (childIndex !== -1) {
|
||||
children.splice(childIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private rescueUnassignedRelay(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
): void {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
const candidate = room.viewers.get(peerId);
|
||||
if (
|
||||
!hostPeerId ||
|
||||
!candidate ||
|
||||
!connectedPeerIds.has(hostPeerId) ||
|
||||
!connectedPeerIds.has(peerId) ||
|
||||
candidate.parentPeerId !== null ||
|
||||
candidate.childPeerIds.length !== 0 ||
|
||||
downstreamCapacity(room, peerId) === 0 ||
|
||||
room.hostChildPeerIds.length !== downstreamCapacity(room, hostPeerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leafPeerId = room.hostChildPeerIds.find((childPeerId) => {
|
||||
const child = room.viewers.get(childPeerId);
|
||||
return (
|
||||
connectedPeerIds.has(childPeerId) &&
|
||||
child?.parentPeerId === hostPeerId &&
|
||||
child.childPeerIds.length === 0 &&
|
||||
downstreamCapacity(room, childPeerId) === 0
|
||||
);
|
||||
});
|
||||
if (!leafPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaf = room.viewers.get(leafPeerId)!;
|
||||
const leafIndex = room.hostChildPeerIds.indexOf(leafPeerId);
|
||||
room.hostChildPeerIds.splice(leafIndex, 1, peerId);
|
||||
room.hostChildPeerIds.sort(
|
||||
(left, right) =>
|
||||
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
|
||||
);
|
||||
candidate.parentPeerId = hostPeerId;
|
||||
candidate.childPeerIds.push(leafPeerId);
|
||||
leaf.parentPeerId = peerId;
|
||||
}
|
||||
}
|
||||
|
||||
function findShallowestAvailableParent(
|
||||
room: RelayRoom,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
maximumParentDepth: number,
|
||||
): string | undefined {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
const children = childPeerIds(room, peerId);
|
||||
const capacity = downstreamCapacity(room, peerId);
|
||||
if (children.length < capacity) {
|
||||
return peerId;
|
||||
}
|
||||
for (const childPeerId of children) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findReassignmentParent(
|
||||
room: RelayRoom,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
excludedParentPeerIds: ReadonlySet<string>,
|
||||
subtreePeerIds: ReadonlySet<string>,
|
||||
maximumParentDepth: number,
|
||||
): string | undefined {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
if (depth > maximumParentDepth || !connectedPeerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
const children = childPeerIds(room, peerId);
|
||||
const capacity = downstreamCapacity(room, peerId);
|
||||
if (
|
||||
children.length < capacity &&
|
||||
!excludedParentPeerIds.has(peerId) &&
|
||||
!subtreePeerIds.has(peerId)
|
||||
) {
|
||||
return peerId;
|
||||
}
|
||||
for (const childPeerId of children) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function downstreamCapacity(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
return advertisedDownstreamCapacity(room, peerId);
|
||||
}
|
||||
|
||||
function advertisedDownstreamCapacity(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
return room.hostPeerId === peerId
|
||||
? room.endpointMediaCopyCapacity
|
||||
: (room.viewers.get(peerId)?.downstreamEdges ?? 0);
|
||||
}
|
||||
|
||||
function collectSubtreePeerIds(room: RelayRoom, rootPeerId: string): Set<string> {
|
||||
const peerIds = new Set<string>();
|
||||
const queue = [rootPeerId];
|
||||
while (queue.length > 0) {
|
||||
const peerId = queue.shift()!;
|
||||
if (peerIds.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
peerIds.add(peerId);
|
||||
queue.push(...(room.viewers.get(peerId)?.childPeerIds ?? []));
|
||||
}
|
||||
return peerIds;
|
||||
}
|
||||
|
||||
function maximumSubtreeDepth(room: RelayRoom, rootPeerId: string): number {
|
||||
let maximumDepth = 0;
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: rootPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
for (const childPeerId of room.viewers.get(peerId)?.childPeerIds ?? []) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function maximumDepthOutsideSubtree(
|
||||
room: RelayRoom,
|
||||
excludedPeerIds: ReadonlySet<string>,
|
||||
): number {
|
||||
const hostPeerId = room.hostPeerId;
|
||||
if (!hostPeerId) {
|
||||
return 0;
|
||||
}
|
||||
let maximumDepth = 0;
|
||||
const queue: Array<{ peerId: string; depth: number }> = [
|
||||
{ peerId: hostPeerId, depth: 0 },
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const { peerId, depth } = queue.shift()!;
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
for (const childPeerId of childPeerIds(room, peerId)) {
|
||||
if (!excludedPeerIds.has(childPeerId)) {
|
||||
queue.push({ peerId: childPeerId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function childPeerIds(room: RelayRoom, peerId: string): string[] {
|
||||
if (room.hostPeerId === peerId) {
|
||||
return room.hostChildPeerIds;
|
||||
}
|
||||
const viewer = room.viewers.get(peerId);
|
||||
if (!viewer) {
|
||||
throw new Error("Peer relay topology references an unknown parent");
|
||||
}
|
||||
return viewer.childPeerIds;
|
||||
}
|
||||
|
||||
function orderedViewers(room: RelayRoom): Array<[string, RelayViewer]> {
|
||||
return [...room.viewers.entries()].sort(
|
||||
([, left], [, right]) => left.order - right.order,
|
||||
);
|
||||
}
|
||||
|
||||
function snapshot(room: RelayRoom): Map<string, MediaAssignment> {
|
||||
const assignments = new Map<string, MediaAssignment>();
|
||||
if (room.hostPeerId) {
|
||||
assignments.set(room.hostPeerId, {
|
||||
parentPeerId: null,
|
||||
childPeerIds: [...room.hostChildPeerIds],
|
||||
});
|
||||
}
|
||||
for (const [peerId, viewer] of room.viewers) {
|
||||
assignments.set(peerId, {
|
||||
parentPeerId: viewer.parentPeerId,
|
||||
childPeerIds: [...viewer.childPeerIds],
|
||||
});
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
|
||||
function changedAssignments(
|
||||
before: ReadonlyMap<string, MediaAssignment>,
|
||||
after: ReadonlyMap<string, MediaAssignment>,
|
||||
): MediaAssignmentChange[] {
|
||||
const changes: MediaAssignmentChange[] = [];
|
||||
for (const [peerId, mediaAssignment] of after) {
|
||||
const previous = before.get(peerId);
|
||||
if (
|
||||
!previous ||
|
||||
previous.parentPeerId !== mediaAssignment.parentPeerId ||
|
||||
previous.childPeerIds.length !== mediaAssignment.childPeerIds.length ||
|
||||
previous.childPeerIds.some(
|
||||
(childPeerId, index) =>
|
||||
childPeerId !== mediaAssignment.childPeerIds[index],
|
||||
)
|
||||
) {
|
||||
changes.push({
|
||||
peerId,
|
||||
previousParentPeerId: previous?.parentPeerId,
|
||||
mediaAssignment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
@@ -96,6 +96,7 @@ interface Participant {
|
||||
joinOrder: number;
|
||||
effectiveDownstreamCapacity: number;
|
||||
blockedAtFactVersion?: number;
|
||||
failedTuple?: { key: string; factVersion: number };
|
||||
}
|
||||
|
||||
interface Attempt<Resource> {
|
||||
@@ -168,6 +169,7 @@ interface HostPublication<Resource> {
|
||||
usable: boolean;
|
||||
physicalActive: boolean;
|
||||
resource: Resource;
|
||||
failedIngress?: { ingress: "direct" | "selected-turn"; factVersion: number };
|
||||
}
|
||||
|
||||
export class RoomRouteController<Resource = unknown> {
|
||||
@@ -176,6 +178,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private hostPublication: HostPublication<Resource> | null = null;
|
||||
private operation?: ChildOperation<Resource>;
|
||||
private revision = 0;
|
||||
private latestRevision = 0;
|
||||
private factVersion = 0;
|
||||
private nextJoinOrder = 0;
|
||||
private paused = false;
|
||||
@@ -210,8 +213,14 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const released: Resource[] = [];
|
||||
const previousSessionId = current.sessionId;
|
||||
if (previousSessionId !== input.sessionId) {
|
||||
const revisionBefore = this.revision;
|
||||
released.push(...this.abortOperation());
|
||||
current.sessionId = input.sessionId;
|
||||
released.push(...this.rebindCommittedSession(input.peerId, input.sessionId));
|
||||
const rebound = this.rebindCommittedSession(input.peerId, input.sessionId);
|
||||
released.push(...rebound.released);
|
||||
if (rebound.retired && this.revision === revisionBefore) {
|
||||
this.revision = this.allocateRevision();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (current.departureConfirmed) {
|
||||
@@ -326,6 +335,10 @@ export class RoomRouteController<Resource = unknown> {
|
||||
edge.usable = false;
|
||||
child.blockedAtFactVersion = undefined;
|
||||
this.touchFacts();
|
||||
child.failedTuple = {
|
||||
key: edgeTupleKey(edge),
|
||||
factVersion: this.factVersion,
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -344,10 +357,42 @@ export class RoomRouteController<Resource = unknown> {
|
||||
if (publication.usable) {
|
||||
publication.usable = false;
|
||||
this.touchFacts();
|
||||
publication.failedIngress = {
|
||||
ingress: publication.ingress,
|
||||
factVersion: this.factVersion,
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
adoptDirectConnection(input: EdgeGuard & { newConnectionId: string }): boolean {
|
||||
const edge = this.upstreamByViewer.get(input.childPeerId);
|
||||
if (
|
||||
this.operation?.childPeerId === input.childPeerId ||
|
||||
this.revision !== input.routeRevision ||
|
||||
!edge ||
|
||||
edge.kind !== "peer" ||
|
||||
edge.transport !== "direct" ||
|
||||
!edge.usable ||
|
||||
!edge.physicalActive ||
|
||||
edge.childSessionId !== input.childSessionId ||
|
||||
edge.parentSessionId !== input.parentSessionId ||
|
||||
edge.connectionId !== input.connectionId ||
|
||||
!input.newConnectionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (input.newConnectionId === edge.connectionId) return true;
|
||||
edge.connectionId = input.newConnectionId;
|
||||
const child = this.participants.get(input.childPeerId);
|
||||
if (child) {
|
||||
child.blockedAtFactVersion = undefined;
|
||||
child.failedTuple = undefined;
|
||||
}
|
||||
this.touchFacts();
|
||||
return true;
|
||||
}
|
||||
|
||||
retireCommittedTransport(guard: EdgeGuard): readonly Resource[] {
|
||||
const child = this.participants.get(guard.childPeerId);
|
||||
const edge = this.upstreamByViewer.get(guard.childPeerId);
|
||||
@@ -360,7 +405,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
child.blockedAtFactVersion = undefined;
|
||||
this.revision = this.nextRevision();
|
||||
this.revision = this.allocateRevision();
|
||||
if (this.operation) this.operation.baseRevision = this.revision;
|
||||
this.touchFacts();
|
||||
return edge.transport === "direct" ? [] : [edge.resource];
|
||||
@@ -380,7 +425,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
publication.connectionId !== guard.connectionId) return [];
|
||||
publication.physicalActive = false;
|
||||
publication.usable = false;
|
||||
this.revision = this.nextRevision();
|
||||
this.revision = this.allocateRevision();
|
||||
if (this.operation) this.operation.baseRevision = this.revision;
|
||||
this.touchFacts();
|
||||
return [publication.resource];
|
||||
@@ -409,6 +454,10 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const child = this.participants.get(childPeerId)!;
|
||||
const candidates = this.buildCandidates(childPeerId, bootstrap !== undefined);
|
||||
if (candidates.length === 0) {
|
||||
if (this.retireInvalidOperationEdge(childPeerId, released)) {
|
||||
this.revision = this.allocateRevision();
|
||||
this.touchFacts();
|
||||
}
|
||||
child.blockedAtFactVersion = this.factVersion;
|
||||
continue;
|
||||
}
|
||||
@@ -470,7 +519,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
operation.current = {
|
||||
tuple,
|
||||
revision: this.nextRevision(),
|
||||
revision: this.allocateRevision(),
|
||||
connectionId: input.connectionId,
|
||||
childSessionId: operation.childSessionId,
|
||||
parentSessionId: tuple.kind === "peer" ? this.participants.get(tuple.parentPeerId)?.sessionId ?? undefined : undefined,
|
||||
@@ -512,6 +561,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
const released = [...validation.released];
|
||||
const retirement = plan.endpointTransition.retire;
|
||||
let restoreTuple: CandidateTuple | undefined;
|
||||
if (retirement.kind === "edge") {
|
||||
const edge = this.upstreamByViewer.get(retirement.childPeerId);
|
||||
if (!edge || edge.kind !== "peer" || !edge.physicalActive ||
|
||||
@@ -522,6 +572,13 @@ export class RoomRouteController<Resource = unknown> {
|
||||
edge.connectionId !== retirement.connectionId) {
|
||||
return { accepted: false, operation: this.operationSnapshot(), released };
|
||||
}
|
||||
if (edge.usable) {
|
||||
restoreTuple = {
|
||||
kind: "peer",
|
||||
parentPeerId: edge.parentPeerId,
|
||||
transport: edge.transport,
|
||||
};
|
||||
}
|
||||
if (edge.transport === "selected-turn") released.push(edge.resource);
|
||||
this.upstreamByViewer.delete(retirement.childPeerId);
|
||||
} else {
|
||||
@@ -535,13 +592,13 @@ export class RoomRouteController<Resource = unknown> {
|
||||
released.push(...this.removePublicationGeneration(retirement.generation));
|
||||
}
|
||||
this.advanceActiveRevision(operation);
|
||||
this.touchFacts();
|
||||
const nextTuple = retirement.kind === "publication" && plan.tuple.kind === "sfu" &&
|
||||
plan.tuple.publication === "replace"
|
||||
? { ...plan.tuple, publication: "create" as const }
|
||||
: plan.tuple;
|
||||
const nextPlan = this.planCandidate(operation.childPeerId, nextTuple);
|
||||
if (!nextPlan) {
|
||||
operation.cursor += 1;
|
||||
this.replanRemaining(operation, nextTuple, restoreTuple);
|
||||
if (operation.cursor >= operation.candidates.length) {
|
||||
const advanced = this.validateOrAdvance(nowMs);
|
||||
return {
|
||||
accepted: true,
|
||||
@@ -550,17 +607,29 @@ export class RoomRouteController<Resource = unknown> {
|
||||
released: [...released, ...advanced.released],
|
||||
};
|
||||
}
|
||||
operation.candidates[operation.cursor] = nextPlan;
|
||||
return { accepted: true, operation: this.operationSnapshot(), released };
|
||||
}
|
||||
|
||||
candidateReady(guard: CandidateGuard, nowMs: number): SettleResult<Resource> {
|
||||
candidateReady(
|
||||
guard: CandidateGuard,
|
||||
nowMs: number,
|
||||
commitReservation: (reservation: CandidateReservation<Resource>) => boolean = () => true,
|
||||
): SettleResult<Resource> {
|
||||
const validation = this.validateOrAdvance(nowMs);
|
||||
const operation = this.operation;
|
||||
const attempt = operation?.current;
|
||||
if (!operation || !attempt || !this.guardMatches(guard, operation, attempt)) {
|
||||
return { accepted: false, exhausted: validation.exhausted, activeRevision: this.revision, released: validation.released };
|
||||
}
|
||||
if (!commitReservation(attempt.reservation)) {
|
||||
const failed = this.validateOrAdvance(nowMs, guard);
|
||||
return {
|
||||
accepted: false,
|
||||
exhausted: failed.exhausted,
|
||||
activeRevision: this.revision,
|
||||
released: [...validation.released, ...failed.released],
|
||||
};
|
||||
}
|
||||
const released = [...validation.released, ...this.commitAttempt(operation, attempt)];
|
||||
return { accepted: true, activeRevision: this.revision, released };
|
||||
}
|
||||
@@ -578,6 +647,22 @@ export class RoomRouteController<Resource = unknown> {
|
||||
return { accepted: validation.expired, exhausted: validation.exhausted, activeRevision: this.revision, released: validation.released };
|
||||
}
|
||||
|
||||
dispose(): readonly Resource[] {
|
||||
const resources = new Set<Resource>();
|
||||
if (this.operation?.current) {
|
||||
for (const resource of reservationResources(this.operation.current.reservation)) {
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
for (const resource of this.committedResources()) resources.add(resource);
|
||||
this.operation = undefined;
|
||||
this.upstreamByViewer.clear();
|
||||
this.hostPublication = null;
|
||||
this.participants.clear();
|
||||
this.paused = true;
|
||||
return [...resources];
|
||||
}
|
||||
|
||||
private validateOrAdvance(nowMs: number, failedGuard?: CandidateGuard): {
|
||||
released: Resource[]; exhausted?: boolean; expired: boolean; consumedGuard: boolean;
|
||||
} {
|
||||
@@ -589,7 +674,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
if (operation.current) released.push(...reservationResources(operation.current.reservation));
|
||||
if (operation.current) this.advanceActiveRevision(operation);
|
||||
const factsChanged = operation.builtAtFactVersion !== this.factVersion;
|
||||
this.blockAndClear(operation, !factsChanged);
|
||||
this.blockAndClear(operation, !factsChanged, released);
|
||||
return { ...result, exhausted: !factsChanged, expired: true };
|
||||
}
|
||||
if (this.participants.get(operation.childPeerId)?.sessionId !== operation.childSessionId) {
|
||||
@@ -601,6 +686,9 @@ export class RoomRouteController<Resource = unknown> {
|
||||
|
||||
while ((operation = this.operation)) {
|
||||
const attempt = operation.current;
|
||||
if (!attempt && operation.builtAtFactVersion !== this.factVersion) {
|
||||
this.replanRemaining(operation);
|
||||
}
|
||||
if (attempt) {
|
||||
const guardFailed = failedGuard && this.guardMatches(failedGuard, operation, attempt);
|
||||
const plan = operation.candidates[operation.cursor];
|
||||
@@ -610,6 +698,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
operation.current = undefined;
|
||||
operation.cursor += 1;
|
||||
result.consumedGuard = Boolean(guardFailed);
|
||||
this.replanRemaining(operation);
|
||||
}
|
||||
while (operation.cursor < operation.candidates.length &&
|
||||
!this.candidateValid(operation.childPeerId, operation.candidates[operation.cursor]!)) {
|
||||
@@ -617,21 +706,18 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
if (operation.cursor < operation.candidates.length) return result;
|
||||
const factsChanged = operation.builtAtFactVersion !== this.factVersion;
|
||||
this.blockAndClear(operation, !factsChanged);
|
||||
this.blockAndClear(operation, !factsChanged, released);
|
||||
result.exhausted = !factsChanged;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private commitAttempt(operation: ChildOperation<Resource>, attempt: Attempt<Resource>): Resource[] {
|
||||
const released: Resource[] = [];
|
||||
const beforeResources = new Set(this.committedResources());
|
||||
const old = this.upstreamByViewer.get(operation.childPeerId);
|
||||
if ("overlap" in attempt.reservation && attempt.reservation.overlap !== undefined) released.push(attempt.reservation.overlap);
|
||||
|
||||
if (attempt.tuple.kind === "sfu" && attempt.tuple.publication === "replace") {
|
||||
released.push(...this.removePublicationGeneration(this.hostPublication!.generation));
|
||||
} else if (old && old.transport !== "direct" && old.physicalActive) {
|
||||
released.push(old.resource);
|
||||
this.removePublicationGeneration(this.hostPublication!.generation);
|
||||
}
|
||||
|
||||
if (attempt.tuple.kind === "peer") {
|
||||
@@ -676,18 +762,30 @@ export class RoomRouteController<Resource = unknown> {
|
||||
this.operation = undefined;
|
||||
this.touchFacts();
|
||||
const participant = this.participants.get(operation.childPeerId);
|
||||
if (participant) participant.blockedAtFactVersion = undefined;
|
||||
if (participant) {
|
||||
participant.blockedAtFactVersion = undefined;
|
||||
participant.failedTuple = undefined;
|
||||
}
|
||||
if (old?.kind === "sfu" && !this.hasSfuSubscribers() && this.hostPublication) {
|
||||
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
|
||||
this.hostPublication = null;
|
||||
}
|
||||
this.assertGraph();
|
||||
const afterResources = new Set(this.committedResources());
|
||||
const released = [...beforeResources].filter((resource) => !afterResources.has(resource));
|
||||
if ("overlap" in attempt.reservation && attempt.reservation.overlap !== undefined) {
|
||||
released.push(attempt.reservation.overlap);
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
private buildCandidates(childPeerId: string, sfuOnly: boolean): CandidatePlan[] {
|
||||
const failed = this.upstreamByViewer.get(childPeerId);
|
||||
const failedKey = failed?.usable === false ? edgeTupleKey(failed) : undefined;
|
||||
const child = this.participants.get(childPeerId)!;
|
||||
const failedKey = child.failedTuple?.factVersion === this.factVersion
|
||||
? child.failedTuple.key
|
||||
: undefined;
|
||||
const failedIngress = this.hostPublication?.failedIngress?.factVersion === this.factVersion
|
||||
? this.hostPublication.failedIngress.ingress
|
||||
: undefined;
|
||||
const descendants = this.descendantsOf(childPeerId);
|
||||
const parents = [...this.participants.values()]
|
||||
.filter((parent) => parent.sessionId && !parent.departureConfirmed && parent.peerId !== childPeerId &&
|
||||
@@ -716,6 +814,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
}
|
||||
return candidates.filter((candidate, index, all) => tupleKey(candidate) !== failedKey &&
|
||||
!(candidate.kind === "sfu" && candidate.publication !== "reuse" && candidate.ingress === failedIngress) &&
|
||||
all.findIndex((other) => tupleKey(other) === tupleKey(candidate)) === index)
|
||||
.map((candidate) => this.planCandidate(childPeerId, candidate))
|
||||
.filter((candidate): candidate is CandidatePlan => candidate !== null &&
|
||||
@@ -749,7 +848,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const participant = this.participants.get(id);
|
||||
const edge = this.upstreamByViewer.get(id);
|
||||
return participant?.sessionId && participant.blockedAtFactVersion !== this.factVersion &&
|
||||
edge?.kind === "peer" && edge.transport === "direct" && edge.usable;
|
||||
edge?.kind === "peer" && edge.transport === "direct" && edge.usable && edge.physicalActive;
|
||||
})
|
||||
.sort((left, right) => compareParticipant(this.participants.get(right)!, this.participants.get(left)!))[0];
|
||||
}
|
||||
@@ -764,16 +863,29 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private overflowChildren(): string[] {
|
||||
const result: string[] = [];
|
||||
for (const parent of this.participants.values()) {
|
||||
const capacity = Math.max(0, parent.effectiveDownstreamCapacity - (parent.role === "host" && this.hostPublication ? 1 : 0));
|
||||
const children = this.childrenOf(parent.peerId).filter((id) => {
|
||||
const overflow = this.overflowPeerChildren(parent.peerId).filter((id) => {
|
||||
const child = this.participants.get(id);
|
||||
return child?.sessionId && !child.departureConfirmed && child.blockedAtFactVersion !== this.factVersion;
|
||||
}).sort((left, right) => compareParticipant(this.participants.get(left)!, this.participants.get(right)!));
|
||||
result.push(...children.slice(capacity).reverse());
|
||||
});
|
||||
result.push(...overflow.reverse());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private overflowPeerChildren(parentPeerId: string): string[] {
|
||||
const parent = this.participants.get(parentPeerId);
|
||||
if (!parent) return [];
|
||||
const publicationCopies = parent.role === "host" && this.hostPublication?.physicalActive ? 1 : 0;
|
||||
const capacity = Math.max(0, parent.effectiveDownstreamCapacity - publicationCopies);
|
||||
return this.childrenOf(parentPeerId)
|
||||
.filter((id) => {
|
||||
const edge = this.upstreamByViewer.get(id);
|
||||
return edge?.kind === "peer" && edge.physicalActive;
|
||||
})
|
||||
.sort((left, right) => compareParticipant(this.participants.get(left)!, this.participants.get(right)!))
|
||||
.slice(capacity);
|
||||
}
|
||||
|
||||
private candidateValid(childPeerId: string, plan: CandidatePlan, attempt?: Attempt<Resource>): boolean {
|
||||
const tuple = plan.tuple;
|
||||
const child = this.participants.get(childPeerId);
|
||||
@@ -836,7 +948,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
if (producerPeerId !== this.options.hostPeerId || !this.hostPublication?.physicalActive) return null;
|
||||
const replacesPublication = tuple.kind === "sfu" && tuple.publication === "replace";
|
||||
const releasesLastPublication = tuple.kind === "peer" && old?.kind === "sfu" &&
|
||||
const releasesLastPublication = tuple.kind === "peer" && old?.kind === "sfu" && old.physicalActive &&
|
||||
this.sfuSubscriberCount() === 1;
|
||||
if (!replacesPublication && !releasesLastPublication) return null;
|
||||
return {
|
||||
@@ -883,7 +995,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
|
||||
this.hostPublication = null;
|
||||
}
|
||||
this.revision = this.nextRevision();
|
||||
this.revision = this.allocateRevision();
|
||||
this.touchFacts();
|
||||
}
|
||||
return removed;
|
||||
@@ -898,14 +1010,71 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
|
||||
private advanceActiveRevision(operation: ChildOperation<Resource>): void {
|
||||
this.revision = this.nextRevision();
|
||||
this.revision = this.allocateRevision();
|
||||
operation.baseRevision = this.revision;
|
||||
}
|
||||
|
||||
private blockAndClear(operation: ChildOperation<Resource>, block = true): void {
|
||||
private blockAndClear(
|
||||
operation: ChildOperation<Resource>,
|
||||
block = true,
|
||||
released: Resource[] = [],
|
||||
): void {
|
||||
const child = this.participants.get(operation.childPeerId);
|
||||
if (block && child) child.blockedAtFactVersion = this.factVersion;
|
||||
this.operation = undefined;
|
||||
if (!block || !child) return;
|
||||
if (this.retireInvalidOperationEdge(operation.childPeerId, released)) {
|
||||
this.revision = this.allocateRevision();
|
||||
this.touchFacts();
|
||||
}
|
||||
child.blockedAtFactVersion = this.factVersion;
|
||||
}
|
||||
|
||||
private retireInvalidOperationEdge(childPeerId: string, released: Resource[]): boolean {
|
||||
const edge = this.upstreamByViewer.get(childPeerId);
|
||||
if (!edge || !this.edgeRequiresMove(childPeerId, edge)) return false;
|
||||
if (edge.transport !== "direct" && edge.physicalActive) released.push(edge.resource);
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
if (edge.kind === "sfu" && !this.hasSfuSubscribers() && this.hostPublication) {
|
||||
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
|
||||
this.hostPublication.physicalActive = false;
|
||||
this.hostPublication.usable = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private edgeRequiresMove(childPeerId: string, edge: CommittedEdge<Resource>): boolean {
|
||||
if (!edge.usable || !edge.physicalActive) return true;
|
||||
if (edge.kind === "sfu") {
|
||||
return !this.hostPublication?.usable ||
|
||||
!this.hostPublication.physicalActive ||
|
||||
this.hostPublication.generation !== edge.publicationGeneration;
|
||||
}
|
||||
if (this.participants.get(edge.parentPeerId)?.departureConfirmed) return true;
|
||||
return this.overflowPeerChildren(edge.parentPeerId).includes(childPeerId);
|
||||
}
|
||||
|
||||
private replanRemaining(
|
||||
operation: ChildOperation<Resource>,
|
||||
firstTuple?: CandidateTuple,
|
||||
restoreTuple?: CandidateTuple,
|
||||
): void {
|
||||
const prefix = operation.candidates.slice(0, operation.cursor);
|
||||
const tuples = operation.candidates
|
||||
.slice(operation.cursor)
|
||||
.map((candidate) => candidate.tuple);
|
||||
if (firstTuple) {
|
||||
if (tuples.length === 0) tuples.push(firstTuple);
|
||||
else tuples[0] = firstTuple;
|
||||
}
|
||||
if (restoreTuple && !tuples.some((tuple) => tupleKey(tuple) === tupleKey(restoreTuple))) {
|
||||
tuples.push(restoreTuple);
|
||||
}
|
||||
const replanned = tuples
|
||||
.map((tuple) => this.planCandidate(operation.childPeerId, tuple))
|
||||
.filter((plan): plan is CandidatePlan => plan !== null);
|
||||
operation.candidates = [...prefix, ...replanned];
|
||||
operation.builtAtFactVersion = this.factVersion;
|
||||
}
|
||||
|
||||
private operationSnapshot(): OperationSnapshot | undefined {
|
||||
@@ -963,8 +1132,8 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private targetFits(parentPeerId: string, childPeerId: string): boolean {
|
||||
const parent = this.participants.get(parentPeerId)!;
|
||||
const edge = this.upstreamByViewer.get(childPeerId);
|
||||
const already = edge?.kind === "peer" && edge.parentPeerId === parentPeerId;
|
||||
const releasesLastPublication = parent.role === "host" && edge?.kind === "sfu" &&
|
||||
const already = edge?.kind === "peer" && edge.parentPeerId === parentPeerId && edge.physicalActive;
|
||||
const releasesLastPublication = parent.role === "host" && edge?.kind === "sfu" && edge.physicalActive &&
|
||||
this.sfuSubscriberCount() === 1;
|
||||
return this.usedSlots(parentPeerId) - (releasesLastPublication ? 1 : 0) +
|
||||
(already ? 0 : 1) <= parent.effectiveDownstreamCapacity;
|
||||
@@ -973,9 +1142,10 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private publicationFits(childPeerId: string, replacing: boolean): boolean {
|
||||
const host = this.participants.get(this.options.hostPeerId);
|
||||
const edge = this.upstreamByViewer.get(childPeerId);
|
||||
const releasesHost = edge?.kind === "peer" && edge.parentPeerId === this.options.hostPeerId;
|
||||
const releasesHost = edge?.kind === "peer" && edge.parentPeerId === this.options.hostPeerId && edge.physicalActive;
|
||||
const replacesPhysicalPublication = replacing && Boolean(this.hostPublication?.physicalActive);
|
||||
const nextSlots = this.usedSlots(this.options.hostPeerId) - (releasesHost ? 1 : 0) +
|
||||
(replacing ? 0 : 1);
|
||||
(replacesPhysicalPublication ? 0 : 1);
|
||||
return Boolean(host?.sessionId && !host.departureConfirmed && nextSlots <= host.effectiveDownstreamCapacity);
|
||||
}
|
||||
|
||||
@@ -985,14 +1155,13 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
|
||||
private usedSlots(peerId: string): number {
|
||||
const participant = this.participants.get(peerId);
|
||||
return this.childrenOf(peerId).length + (participant?.role === "host" && this.hostPublication ? 1 : 0);
|
||||
return this.physicalCopies(peerId);
|
||||
}
|
||||
|
||||
private remaining(parentPeerId: string, childPeerId: string): number {
|
||||
const parent = this.participants.get(parentPeerId)!;
|
||||
const edge = this.upstreamByViewer.get(childPeerId);
|
||||
const already = edge?.kind === "peer" && edge.parentPeerId === parentPeerId;
|
||||
const already = edge?.kind === "peer" && edge.parentPeerId === parentPeerId && edge.physicalActive;
|
||||
return parent.effectiveDownstreamCapacity - this.usedSlots(parentPeerId) - (already ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -1012,43 +1181,59 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
|
||||
private hasSfuSubscribers(): boolean {
|
||||
return [...this.upstreamByViewer.values()].some((edge) => edge.kind === "sfu");
|
||||
return [...this.upstreamByViewer.values()].some((edge) => edge.kind === "sfu" && edge.physicalActive);
|
||||
}
|
||||
|
||||
private sfuSubscriberCount(): number {
|
||||
return [...this.upstreamByViewer.values()].filter((edge) => edge.kind === "sfu").length;
|
||||
return [...this.upstreamByViewer.values()].filter((edge) => edge.kind === "sfu" && edge.physicalActive).length;
|
||||
}
|
||||
|
||||
private rebindCommittedSession(peerId: string, sessionId: string): Resource[] {
|
||||
const released: Resource[] = [];
|
||||
private committedResources(): Resource[] {
|
||||
const resources: Resource[] = [];
|
||||
for (const edge of this.upstreamByViewer.values()) {
|
||||
if (edge.transport !== "direct" && edge.physicalActive) resources.push(edge.resource);
|
||||
}
|
||||
if (this.hostPublication?.physicalActive) resources.push(this.hostPublication.resource);
|
||||
return resources;
|
||||
}
|
||||
|
||||
private rebindCommittedSession(
|
||||
peerId: string,
|
||||
sessionId: string,
|
||||
): { released: Resource[]; retired: boolean } {
|
||||
const released = new Set<Resource>();
|
||||
let retired = false;
|
||||
const ownEdge = this.upstreamByViewer.get(peerId);
|
||||
if (ownEdge?.kind === "peer" && ownEdge.transport === "selected-turn") {
|
||||
if (ownEdge.physicalActive) released.push(ownEdge.resource);
|
||||
if (ownEdge.physicalActive) released.add(ownEdge.resource);
|
||||
ownEdge.physicalActive = false;
|
||||
ownEdge.usable = false;
|
||||
retired = true;
|
||||
} else if (ownEdge) {
|
||||
ownEdge.childSessionId = sessionId;
|
||||
}
|
||||
for (const edge of this.upstreamByViewer.values()) {
|
||||
if (edge.kind !== "peer" || edge.parentPeerId !== peerId) continue;
|
||||
if (edge.transport === "selected-turn") {
|
||||
if (edge.physicalActive) released.push(edge.resource);
|
||||
if (edge.physicalActive) released.add(edge.resource);
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
retired = true;
|
||||
} else {
|
||||
edge.parentSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
if (peerId === this.options.hostPeerId && this.hostPublication) {
|
||||
if (this.hostPublication.ingress === "selected-turn") {
|
||||
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
|
||||
if (this.hostPublication.physicalActive) released.add(this.hostPublication.resource);
|
||||
this.hostPublication.physicalActive = false;
|
||||
this.hostPublication.usable = false;
|
||||
retired = true;
|
||||
} else {
|
||||
this.hostPublication.hostSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
return released;
|
||||
return { released: [...released], retired };
|
||||
}
|
||||
|
||||
private removePublicationGeneration(generation: string): Resource[] {
|
||||
@@ -1099,9 +1284,10 @@ export class RoomRouteController<Resource = unknown> {
|
||||
|
||||
private touchFacts(): void { this.factVersion += 1; }
|
||||
|
||||
private nextRevision(): number {
|
||||
if (this.revision >= MAX_MEDIA_ROUTE_REVISION) throw new Error("Media route revision space exhausted");
|
||||
return this.revision + 1;
|
||||
private allocateRevision(): number {
|
||||
if (this.latestRevision >= MAX_MEDIA_ROUTE_REVISION) throw new Error("Media route revision space exhausted");
|
||||
this.latestRevision += 1;
|
||||
return this.latestRevision;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ interface PublicationEntry {
|
||||
fence: SfuResourceFence;
|
||||
state: ResourceState;
|
||||
subscriptions: Map<string, SubscriptionEntry>;
|
||||
legacyEgress?: number;
|
||||
}
|
||||
|
||||
interface RoomResources {
|
||||
@@ -115,6 +114,9 @@ export class SfuResourceAdmission {
|
||||
}
|
||||
if (publication.state === "committed") return [];
|
||||
if (publication.state !== "reserved") return null;
|
||||
if (![...publication.subscriptions.values()].some((entry) => entry.state === "reserved")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const draining: SfuResourceFence[] = [];
|
||||
for (const other of room.publications.values()) {
|
||||
@@ -177,54 +179,6 @@ export class SfuResourceAdmission {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Compatibility wrapper for the pre-controller aggregate reservation. */
|
||||
reserve(fence: SfuResourceFence, egress: number): boolean {
|
||||
assertFence(fence);
|
||||
assertPositiveSafeInteger(egress, "SFU egress reservation");
|
||||
const existing = this.publication(fence);
|
||||
if (existing) {
|
||||
return (
|
||||
existing.state !== "draining" && existing.legacyEgress === egress
|
||||
);
|
||||
}
|
||||
const room = this.rooms.get(fence.roomId);
|
||||
if (
|
||||
this.ingressInUse >= this.ingressCapacity ||
|
||||
egress > this.egressCapacity - this.egressInUse ||
|
||||
[...(room?.publications.values() ?? [])].some(
|
||||
(publication) => publication.state === "reserved",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resources = room ?? { publications: new Map() };
|
||||
const publication: PublicationEntry = {
|
||||
fence: cloneFence(fence),
|
||||
state: "reserved",
|
||||
subscriptions: new Map(),
|
||||
legacyEgress: egress,
|
||||
};
|
||||
for (let index = 0; index < egress; index += 1) {
|
||||
const viewerPeerId = `legacy_${index}`;
|
||||
publication.subscriptions.set(viewerPeerId, {
|
||||
fence: { ...cloneFence(fence), viewerPeerId },
|
||||
state: "reserved",
|
||||
reservedFromDraining: false,
|
||||
});
|
||||
}
|
||||
resources.publications.set(publicationKey(fence), publication);
|
||||
this.rooms.set(fence.roomId, resources);
|
||||
this.ingressInUse += 1;
|
||||
this.egressInUse += egress;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Compatibility wrapper for the pre-controller aggregate commit. */
|
||||
commit(fence: SfuResourceFence): readonly SfuResourceFence[] | null {
|
||||
return this.commitPublication(fence);
|
||||
}
|
||||
|
||||
beginDrain(fence: SfuResourceFence): boolean {
|
||||
assertFence(fence);
|
||||
const publication = this.publication(fence);
|
||||
|
||||
+12
-26
@@ -549,7 +549,7 @@ export class SignalingServer {
|
||||
? this.hybridMediaRouter!.connectParticipant(routeParticipant)
|
||||
: undefined;
|
||||
} catch {
|
||||
console.error("Peer relay topology did not assign an authenticated participant");
|
||||
console.error("Media router did not assign an authenticated participant");
|
||||
this.sendError(socket, "SERVER_ERROR", "Media assignment failed");
|
||||
socket.close(1011, "Media assignment failed");
|
||||
return;
|
||||
@@ -629,6 +629,13 @@ export class SignalingServer {
|
||||
}
|
||||
|
||||
if (hybridState) {
|
||||
if (participant.role === "host") {
|
||||
this.hybridMediaRouter!.setPaused(
|
||||
participant.roomId,
|
||||
this.pausedShareGenerationsByRoom.get(participant.roomId) ===
|
||||
shareGeneration,
|
||||
);
|
||||
}
|
||||
this.hybridMediaRouter!.completeAuthentication(
|
||||
routeParticipant,
|
||||
hybridState,
|
||||
@@ -785,16 +792,6 @@ export class SignalingServer {
|
||||
message.downstreamEdges,
|
||||
);
|
||||
return;
|
||||
case "sfu-reselection-ready":
|
||||
if (!this.isHybridMediaEnabled()) {
|
||||
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
|
||||
return;
|
||||
}
|
||||
this.hybridMediaRouter!.handleHealthySfuReselection(
|
||||
{ ...authenticated, sessionId: this.socketStates.get(socket)!.sessionId },
|
||||
message.revision,
|
||||
);
|
||||
return;
|
||||
case "route-ready":
|
||||
if (!this.isHybridMediaEnabled()) {
|
||||
this.sendError(socket, "FORBIDDEN", "Media routes are not enabled");
|
||||
@@ -940,6 +937,9 @@ export class SignalingServer {
|
||||
} else {
|
||||
this.pausedShareGenerationsByRoom.delete(authenticated.roomId);
|
||||
}
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.setPaused(authenticated.roomId, message.paused);
|
||||
}
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(
|
||||
authenticated.roomId,
|
||||
)) {
|
||||
@@ -1205,15 +1205,6 @@ export class SignalingServer {
|
||||
acceptedAtMs: now,
|
||||
parentEvidenceAccepted: false,
|
||||
});
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.handleViewerQualityEvidence({
|
||||
roomId: source.roomId,
|
||||
viewerSessionId: viewerState.sessionId,
|
||||
parentSessionId: parent.sessionId,
|
||||
evidence: forwarded,
|
||||
});
|
||||
}
|
||||
|
||||
const host = this.options.roomStore.getConnectedHost(source.roomId);
|
||||
if (!host || host.sessionId === parent.sessionId) {
|
||||
return;
|
||||
@@ -1282,12 +1273,6 @@ export class SignalingServer {
|
||||
}
|
||||
|
||||
gate.parentEvidenceAccepted = true;
|
||||
this.hybridMediaRouter!.handleParentEdgeQualityEvidence({
|
||||
roomId: source.roomId,
|
||||
viewerSessionId: viewer.sessionId,
|
||||
parentSessionId: sourceState.sessionId,
|
||||
evidence: message,
|
||||
});
|
||||
}
|
||||
|
||||
private routeSignal(
|
||||
@@ -1517,6 +1502,7 @@ export class SignalingServer {
|
||||
this.hybridMediaRouter!.disconnectParticipant(
|
||||
disconnected.roomId,
|
||||
disconnected.peerId,
|
||||
state.sessionId,
|
||||
);
|
||||
}
|
||||
if (disconnected.role === "host") {
|
||||
|
||||
+31
-15
@@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
||||
export const MAX_VIEWERS_PER_ROOM_LIMIT = 20;
|
||||
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
||||
export const SIGNALING_PROTOCOL = "screener-v6";
|
||||
export const SIGNALING_PROTOCOL = "screener-v7";
|
||||
export const ROOM_CODE_LENGTH = 12;
|
||||
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
||||
@@ -343,6 +343,17 @@ export const signalPayloadSchema = z.discriminatedUnion("kind", [
|
||||
]);
|
||||
export type SignalPayload = z.infer<typeof signalPayloadSchema>;
|
||||
|
||||
export const preparedRouteCandidateSchema = z
|
||||
.object({
|
||||
childPeerId: opaqueIdSchema,
|
||||
connectionId: opaqueIdSchema,
|
||||
transport: z.enum(["direct", "selected-turn", "sfu"]),
|
||||
})
|
||||
.strict();
|
||||
export type PreparedRouteCandidate = z.infer<
|
||||
typeof preparedRouteCandidateSchema
|
||||
>;
|
||||
|
||||
export const mediaAssignmentSchema = z
|
||||
.object({
|
||||
parentPeerId: opaqueIdSchema.nullable(),
|
||||
@@ -571,12 +582,6 @@ export const clientMessageSchema = z.union([
|
||||
downstreamEdges: relayDownstreamEdgesSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("sfu-reselection-ready"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-ready"),
|
||||
@@ -736,14 +741,25 @@ export const serverMessageSchema = z.union([
|
||||
mediaAssignment: mediaAssignmentSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-update"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: mediaRoutePhaseSchema,
|
||||
assignment: participantRouteAssignmentSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.discriminatedUnion("phase", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-update"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: z.literal("prepare"),
|
||||
assignment: participantRouteAssignmentSchema,
|
||||
candidate: preparedRouteCandidateSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("route-update"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
phase: z.literal("active"),
|
||||
assignment: participantRouteAssignmentSchema,
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("sfu-config"),
|
||||
|
||||
@@ -640,7 +640,7 @@ describe("client signaling recovery policy", () => {
|
||||
Object.defineProperty(authenticated, "data", {
|
||||
value: JSON.stringify({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v6",
|
||||
protocol: "screener-v7",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -714,7 +714,7 @@ describe("client signaling recovery policy", () => {
|
||||
socket.dispatchEvent(new Event("open"));
|
||||
receive(socket, {
|
||||
type: "authenticated",
|
||||
protocol: "screener-v6",
|
||||
protocol: "screener-v7",
|
||||
role: "host",
|
||||
peerId: "host_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -839,7 +839,7 @@ describe("client signaling recovery policy", () => {
|
||||
sockets[0]!.dispatchEvent(new Event("open"));
|
||||
receive({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v6",
|
||||
protocol: "screener-v7",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -967,7 +967,7 @@ describe("client signaling recovery policy", () => {
|
||||
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
|
||||
).toMatchObject({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v6",
|
||||
protocol: "screener-v7",
|
||||
});
|
||||
const message = new Event("message");
|
||||
Object.defineProperty(message, "data", { value: payload });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DecodedFrameStallDetector,
|
||||
ROUTE_DECODED_FRAME_STALL_MS,
|
||||
} from "../src/client/media/decoded-frame-stall.ts";
|
||||
|
||||
describe("DecodedFrameStallDetector", () => {
|
||||
it("reports one exact active stall and resets on progress or identity change", () => {
|
||||
const detector = new DecodedFrameStallDetector();
|
||||
expect(detector.observe("peer:1:a", 0, 0)).toBe(false);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS),
|
||||
).toBe(true);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS + 1),
|
||||
).toBe(false);
|
||||
expect(detector.observe("peer:1:a", 1, ROUTE_DECODED_FRAME_STALL_MS + 2)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 2 + 2),
|
||||
).toBe(true);
|
||||
expect(detector.observe("sfu:2:b", 0, ROUTE_DECODED_FRAME_STALL_MS * 3)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses and rebases progress while paused", () => {
|
||||
const detector = new DecodedFrameStallDetector();
|
||||
detector.observe("peer:1:a", 0, 0);
|
||||
detector.setPaused(true, ROUTE_DECODED_FRAME_STALL_MS);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 3),
|
||||
).toBe(false);
|
||||
detector.setPaused(false, ROUTE_DECODED_FRAME_STALL_MS * 3);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 4 - 1),
|
||||
).toBe(false);
|
||||
expect(
|
||||
detector.observe("peer:1:a", 0, ROUTE_DECODED_FRAME_STALL_MS * 4),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1009,6 +1009,11 @@ function hostProvisionalInput(
|
||||
) {
|
||||
return {
|
||||
revision,
|
||||
candidate: {
|
||||
childPeerId: childPeerIds.at(-1) ?? "candidate-child",
|
||||
connectionId: `candidate-connection-${revision}`,
|
||||
transport: "direct" as const,
|
||||
},
|
||||
assignment: hostAssignment(
|
||||
childPeerIds,
|
||||
overrides.publicationGeneration ?? null,
|
||||
@@ -1138,49 +1143,16 @@ describe("Host provisional child runtime ownership", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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({ peerId, connectionId: payload.connectionId });
|
||||
return true;
|
||||
},
|
||||
activeConnectionId: () => activeConnection.id,
|
||||
});
|
||||
const input = hostProvisionalInput(
|
||||
14,
|
||||
["selected-child"],
|
||||
createStream(createTrack("video", "selected-video"), null),
|
||||
{ activeChildPeerIds: ["selected-child"] },
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ViewerRelay downstream ownership", () => {
|
||||
const routeCandidate = (
|
||||
revision: number,
|
||||
childPeerId: string,
|
||||
transport: "direct" | "selected-turn" = "direct",
|
||||
connectionId = `relay-candidate-${revision}`,
|
||||
) => ({ childPeerId, connectionId, transport });
|
||||
|
||||
it("promotes the exact prepared child connection within the current Viewer cap", async () => {
|
||||
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
||||
const relay = new ViewerRelay(
|
||||
@@ -1194,7 +1166,7 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
},
|
||||
);
|
||||
relay.setStream(createStream(createTrack("video", "prepared-video"), null));
|
||||
expect(relay.prepareChild(7, ["prepared-child"])).toBe(true);
|
||||
expect(relay.prepareChild(7, routeCandidate(7, "prepared-child"), ["prepared-child"])).toBe(true);
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
||||
const preparedConnection = FakePeerConnection.latest!;
|
||||
const preparedConnectionId = signals[0]!.connectionId;
|
||||
@@ -1257,9 +1229,9 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
{ sendSignal: () => true },
|
||||
);
|
||||
relay.setStream(createStream(createTrack("video", "rollback-video"), null));
|
||||
expect(relay.prepareChild(7, ["first-probe"])).toBe(true);
|
||||
expect(relay.prepareChild(7, routeCandidate(7, "first-probe"), ["first-probe"])).toBe(true);
|
||||
const firstProbe = FakePeerConnection.latest!;
|
||||
expect(relay.prepareChild(8, ["second-probe"])).toBe(true);
|
||||
expect(relay.prepareChild(8, routeCandidate(8, "second-probe"), ["second-probe"])).toBe(true);
|
||||
const secondProbe = FakePeerConnection.latest!;
|
||||
expect(firstProbe.connectionState).toBe("closed");
|
||||
relay.activateChildren(9, []);
|
||||
@@ -1267,17 +1239,17 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
|
||||
FakePeerConnection.offersFailing = 1;
|
||||
const instancesBeforeFailure = FakePeerConnection.instances.length;
|
||||
expect(relay.prepareChild(10, ["failed-probe"])).toBe(true);
|
||||
expect(relay.prepareChild(10, routeCandidate(10, "failed-probe"), ["failed-probe"])).toBe(true);
|
||||
const failedProbe = FakePeerConnection.latest!;
|
||||
await vi.waitFor(() => expect(failedProbe.connectionState).toBe("closed"));
|
||||
expect(FakePeerConnection.instances).toHaveLength(instancesBeforeFailure + 1);
|
||||
|
||||
expect(relay.prepareChild(11, ["session-probe"])).toBe(true);
|
||||
expect(relay.prepareChild(11, routeCandidate(11, "session-probe"), ["session-probe"])).toBe(true);
|
||||
const sessionProbe = FakePeerConnection.latest!;
|
||||
relay.discardPreparedChild();
|
||||
expect(sessionProbe.connectionState).toBe("closed");
|
||||
|
||||
expect(relay.prepareChild(12, ["stopped-probe"])).toBe(true);
|
||||
expect(relay.prepareChild(12, routeCandidate(12, "stopped-probe"), ["stopped-probe"])).toBe(true);
|
||||
const stoppedProbe = FakePeerConnection.latest!;
|
||||
relay.stop();
|
||||
expect(stoppedProbe.connectionState).toBe("closed");
|
||||
@@ -1297,7 +1269,7 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
},
|
||||
);
|
||||
relay.setStream(createStream(createTrack("video", "initial-video"), null));
|
||||
expect(relay.prepareChild(7, ["prepared-child"])).toBe(true);
|
||||
expect(relay.prepareChild(7, routeCandidate(7, "prepared-child"), ["prepared-child"])).toBe(true);
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
||||
const preparedConnection = FakePeerConnection.latest!;
|
||||
const preparedConnectionId = signals[0]!.connectionId;
|
||||
@@ -1325,21 +1297,21 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
{ sendSignal: () => true },
|
||||
);
|
||||
relay.setStream(createStream(createTrack("video", "cap-video"), null));
|
||||
expect(relay.prepareChild(1, ["child-0"])).toBe(true);
|
||||
expect(relay.prepareChild(1, routeCandidate(1, "child-0"), ["child-0"])).toBe(true);
|
||||
const preparedConnection = FakePeerConnection.latest!;
|
||||
preparedConnection.connectionState = "connected";
|
||||
relay.activateChildren(1, ["child-0"]);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(relay.prepareChild(2, ["child-0", "child-1"])).toBe(true);
|
||||
expect(relay.prepareChild(2, routeCandidate(2, "child-1"), ["child-0", "child-1"])).toBe(true);
|
||||
FakePeerConnection.latest!.connectionState = "connected";
|
||||
relay.activateChildren(2, ["child-0", "child-1"]);
|
||||
expect(relay.prepareChild(3, ["child-0", "child-1", "child-2"])).toBe(
|
||||
expect(relay.prepareChild(3, routeCandidate(3, "child-2"), ["child-0", "child-1", "child-2"])).toBe(
|
||||
true,
|
||||
);
|
||||
FakePeerConnection.latest!.connectionState = "connected";
|
||||
relay.activateChildren(3, ["child-0", "child-1", "child-2"]);
|
||||
expect(
|
||||
relay.prepareChild(4, ["child-0", "child-1", "child-2", "child-3"]),
|
||||
relay.prepareChild(4, routeCandidate(4, "child-3"), ["child-0", "child-1", "child-2", "child-3"]),
|
||||
).toBe(false);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
relay.dispose();
|
||||
@@ -1363,7 +1335,13 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
const activeConnection = FakePeerConnection.latest!;
|
||||
const activeConnectionId = signals[0]!.connectionId;
|
||||
activeConnection.connectionState = "connected";
|
||||
expect(relay.prepareChild(7, ["selected-child"])).toBe(true);
|
||||
expect(
|
||||
relay.prepareChild(
|
||||
7,
|
||||
routeCandidate(7, "selected-child", "selected-turn", "selected-connection"),
|
||||
["selected-child"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(FakePeerConnection.latest).toBe(activeConnection);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ServerMessage } from "../src/shared/protocol.ts";
|
||||
import {
|
||||
HybridMediaRouter,
|
||||
type AuthenticatedRouteParticipant,
|
||||
type HybridAuthenticationState,
|
||||
} from "../src/server/hybrid-media-router.ts";
|
||||
import { RoomStore } from "../src/server/room-store.ts";
|
||||
import { SfuResourceAdmission } from "../src/server/sfu-resource-admission.ts";
|
||||
import { TurnAllocationAdmission } from "../src/server/turn-allocation-admission.ts";
|
||||
import { FakeSfuRoomControl } from "./fake-sfu-room-control.ts";
|
||||
|
||||
const SHARE_GENERATION = "share_generation_12345678";
|
||||
|
||||
function createStore(maxViewersPerRoom = 20) {
|
||||
return new RoomStore({
|
||||
ttlMs: 60_000,
|
||||
maxRooms: 4,
|
||||
maxViewersPerRoom,
|
||||
});
|
||||
}
|
||||
|
||||
function connectHost(store: RoomStore, room: ReturnType<RoomStore["createRoom"]>) {
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "host",
|
||||
token: room.hostToken,
|
||||
clientId: "host_client_12345678",
|
||||
sessionId: "host_session_12345678",
|
||||
});
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
role: "host" as const,
|
||||
peerId: connected.peerId,
|
||||
sessionId: "host_session_12345678",
|
||||
};
|
||||
}
|
||||
|
||||
function connectViewer(
|
||||
store: RoomStore,
|
||||
room: ReturnType<RoomStore["createRoom"]>,
|
||||
suffix: string,
|
||||
): AuthenticatedRouteParticipant {
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
...(room.viewerGrant ? { viewerGrant: room.viewerGrant } : {}),
|
||||
clientId: `viewer_client_${suffix}_12345678`,
|
||||
sessionId: `viewer_session_${suffix}_12345678`,
|
||||
});
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
role: "viewer",
|
||||
peerId: connected.peerId,
|
||||
sessionId: `viewer_session_${suffix}_12345678`,
|
||||
};
|
||||
}
|
||||
|
||||
function complete(
|
||||
router: HybridMediaRouter,
|
||||
participant: AuthenticatedRouteParticipant,
|
||||
): HybridAuthenticationState {
|
||||
const state = router.connectParticipant(participant);
|
||||
router.completeAuthentication(participant, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function preparedFor(
|
||||
sent: Map<string, ServerMessage[]>,
|
||||
sessionId: string,
|
||||
) {
|
||||
return sent
|
||||
.get(sessionId)
|
||||
?.findLast(
|
||||
(message): message is Extract<ServerMessage, { type: "route-update"; phase: "prepare" }> =>
|
||||
message.type === "route-update" && message.phase === "prepare",
|
||||
);
|
||||
}
|
||||
|
||||
function activeAfter(
|
||||
sent: Map<string, ServerMessage[]>,
|
||||
sessionId: string,
|
||||
revision: number,
|
||||
) {
|
||||
return sent
|
||||
.get(sessionId)
|
||||
?.findLast(
|
||||
(message): message is Extract<ServerMessage, { type: "route-update"; phase: "active" }> =>
|
||||
message.type === "route-update" &&
|
||||
message.phase === "active" &&
|
||||
message.revision > revision,
|
||||
);
|
||||
}
|
||||
|
||||
function harness(
|
||||
endpointMediaCopyCapacity: 1 | 2 | 3,
|
||||
withSfu = false,
|
||||
withSelectedTurn = false,
|
||||
) {
|
||||
const store = createStore();
|
||||
const sent = new Map<string, ServerMessage[]>();
|
||||
const connections = new Map<string, string>();
|
||||
const admission = withSfu
|
||||
? new SfuResourceAdmission({ ingressCapacity: 2, egressCapacity: 20 })
|
||||
: undefined;
|
||||
const roomControl = withSfu ? new FakeSfuRoomControl() : undefined;
|
||||
const turnAdmission = withSelectedTurn
|
||||
? new TurnAllocationAdmission({ capacity: 20 })
|
||||
: undefined;
|
||||
const router = new HybridMediaRouter({
|
||||
roomStore: store,
|
||||
endpointMediaCopyCapacity,
|
||||
...(admission && roomControl
|
||||
? {
|
||||
sfuFallback: {
|
||||
url: "wss://sfu.example.test",
|
||||
admission,
|
||||
roomControl,
|
||||
tokenIssuer: {
|
||||
async issueToken({ peerId }) {
|
||||
return `token-${peerId}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(turnAdmission
|
||||
? {
|
||||
selectedEdgeTurn: {
|
||||
config: {
|
||||
urls: ["turn:turn.example.test:3478?transport=udp"] as const,
|
||||
sharedSecret: "t".repeat(32),
|
||||
credentialTtlSeconds: 120,
|
||||
allocationCapacity: 20,
|
||||
},
|
||||
admission: turnAdmission,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
sendToSession(sessionId, message) {
|
||||
const messages = sent.get(sessionId) ?? [];
|
||||
messages.push(message);
|
||||
sent.set(sessionId, messages);
|
||||
},
|
||||
getConnectionId(roomId, viewerPeerId) {
|
||||
return connections.get(`${roomId}:${viewerPeerId}`);
|
||||
},
|
||||
setConnectionId(roomId, viewerPeerId, connectionId) {
|
||||
connections.set(`${roomId}:${viewerPeerId}`, connectionId);
|
||||
},
|
||||
deleteConnectionId(roomId, viewerPeerId) {
|
||||
connections.delete(`${roomId}:${viewerPeerId}`);
|
||||
},
|
||||
getShareGeneration: () => SHARE_GENERATION,
|
||||
});
|
||||
return { store, sent, admission, turnAdmission, router };
|
||||
}
|
||||
|
||||
describe("HybridMediaRouter v7 runtime", () => {
|
||||
it("orders exact prepare, commits only child proof, and rolls back above P", async () => {
|
||||
const { store, sent, router } = harness(2);
|
||||
const room = store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const first = connectViewer(store, room, "first");
|
||||
complete(router, first);
|
||||
|
||||
await vi.waitFor(() => expect(preparedFor(sent, first.sessionId)).toBeDefined());
|
||||
const firstPrepare = preparedFor(sent, first.sessionId)!;
|
||||
expect(firstPrepare.candidate).toMatchObject({
|
||||
childPeerId: first.peerId,
|
||||
transport: "direct",
|
||||
});
|
||||
expect(preparedFor(sent, host.sessionId)?.candidate).toEqual(
|
||||
firstPrepare.candidate,
|
||||
);
|
||||
|
||||
router.handleRouteReady(host, {
|
||||
type: "route-ready",
|
||||
revision: firstPrepare.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
expect(activeAfter(sent, first.sessionId, firstPrepare.revision)).toBeUndefined();
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: firstPrepare.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sent
|
||||
.get(first.sessionId)
|
||||
?.findLast(
|
||||
(message) =>
|
||||
message.type === "route-update" &&
|
||||
message.phase === "active" &&
|
||||
message.revision === firstPrepare.revision,
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
|
||||
const second = connectViewer(store, room, "second");
|
||||
complete(router, second);
|
||||
await vi.waitFor(() => expect(preparedFor(sent, second.sessionId)).toBeDefined());
|
||||
const secondPrepare = preparedFor(sent, second.sessionId)!;
|
||||
router.handleRouteFailed(second, {
|
||||
type: "route-failed",
|
||||
revision: secondPrepare.revision,
|
||||
phase: "prepare",
|
||||
connectionId: secondPrepare.candidate.connectionId,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(activeAfter(sent, second.sessionId, secondPrepare.revision)).toBeDefined(),
|
||||
);
|
||||
expect(activeAfter(sent, second.sessionId, secondPrepare.revision)!.revision).toBeGreaterThan(
|
||||
secondPrepare.revision,
|
||||
);
|
||||
await router.close();
|
||||
});
|
||||
|
||||
it("creates one Host publication and reuses exact Viewer subscriptions", async () => {
|
||||
const { store, sent, admission, router } = harness(1, true);
|
||||
const room = store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const first = connectViewer(store, room, "sfu_first");
|
||||
complete(router, first);
|
||||
const firstDirect = await vi.waitFor(() => {
|
||||
const message = preparedFor(sent, first.sessionId);
|
||||
expect(message).toBeDefined();
|
||||
return message!;
|
||||
});
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: firstDirect.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
|
||||
const second = connectViewer(store, room, "sfu_second");
|
||||
complete(router, second);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
preparedFor(sent, first.sessionId)?.candidate.transport,
|
||||
).toBe("sfu"),
|
||||
);
|
||||
const firstSfu = preparedFor(sent, first.sessionId)!;
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: firstSfu.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(preparedFor(sent, second.sessionId)?.candidate.transport).toBe("sfu"),
|
||||
);
|
||||
const secondSfu = preparedFor(sent, second.sessionId)!;
|
||||
router.handleRouteReady(second, {
|
||||
type: "route-ready",
|
||||
revision: secondSfu.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(admission?.usage()).toEqual({ ingress: 1, egress: 2 }),
|
||||
);
|
||||
const hostConfigs = sent
|
||||
.get(host.sessionId)
|
||||
?.filter((message) => message.type === "sfu-config");
|
||||
expect(hostConfigs).toHaveLength(1);
|
||||
await router.close();
|
||||
});
|
||||
|
||||
it("uses selected TURN only as the exact Viewer-parent edge transport", async () => {
|
||||
const { store, sent, turnAdmission, router } = harness(1, true, true);
|
||||
const room = store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const parent = connectViewer(store, room, "selected_parent");
|
||||
complete(router, parent);
|
||||
await vi.waitFor(() => expect(preparedFor(sent, parent.sessionId)).toBeDefined());
|
||||
const parentPrepare = preparedFor(sent, parent.sessionId)!;
|
||||
router.handleRouteReady(parent, {
|
||||
type: "route-ready",
|
||||
revision: parentPrepare.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
router.setViewerRelayCapacity(parent, 1);
|
||||
|
||||
const child = connectViewer(store, room, "selected_child");
|
||||
complete(router, child);
|
||||
await vi.waitFor(() =>
|
||||
expect(preparedFor(sent, child.sessionId)?.candidate.transport).toBe("direct"),
|
||||
);
|
||||
const direct = preparedFor(sent, child.sessionId)!;
|
||||
expect(direct.assignment.upstream).toEqual({
|
||||
kind: "peer",
|
||||
peerId: parent.peerId,
|
||||
});
|
||||
router.handleRouteReady(child, {
|
||||
type: "route-ready",
|
||||
revision: direct.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
|
||||
router.handleRouteFailed(child, {
|
||||
type: "route-failed",
|
||||
revision: direct.revision,
|
||||
phase: "active",
|
||||
connectionId: direct.candidate.connectionId,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(preparedFor(sent, child.sessionId)?.candidate.transport).toBe(
|
||||
"selected-turn",
|
||||
),
|
||||
);
|
||||
const selected = preparedFor(sent, child.sessionId)!;
|
||||
expect(
|
||||
sent
|
||||
.get(child.sessionId)
|
||||
?.some(
|
||||
(message) =>
|
||||
message.type === "selected-edge-turn" &&
|
||||
message.edgeKind === "peer-selected" &&
|
||||
message.newConnectionId === selected.candidate.connectionId,
|
||||
),
|
||||
).toBe(true);
|
||||
router.handleRouteReady(child, {
|
||||
type: "route-ready",
|
||||
revision: selected.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(turnAdmission?.usage()).toEqual({ allocations: 1 }),
|
||||
);
|
||||
await router.close();
|
||||
expect(turnAdmission?.usage()).toEqual({ allocations: 0 });
|
||||
});
|
||||
|
||||
it("uses selected TURN as one Host-SFU ingress after direct ingress fails", async () => {
|
||||
const { store, sent, turnAdmission, router } = harness(1, true, true);
|
||||
const room = store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const first = connectViewer(store, room, "ingress_first");
|
||||
complete(router, first);
|
||||
await vi.waitFor(() => expect(preparedFor(sent, first.sessionId)).toBeDefined());
|
||||
const direct = preparedFor(sent, first.sessionId)!;
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: direct.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
|
||||
const waiting = connectViewer(store, room, "ingress_waiting");
|
||||
complete(router, waiting);
|
||||
await vi.waitFor(() =>
|
||||
expect(preparedFor(sent, first.sessionId)?.candidate.transport).toBe("sfu"),
|
||||
);
|
||||
const directIngress = preparedFor(sent, first.sessionId)!;
|
||||
router.handleRouteFailed(first, {
|
||||
type: "route-failed",
|
||||
revision: directIngress.revision,
|
||||
phase: "prepare",
|
||||
connectionId: directIngress.candidate.connectionId,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const prepared = preparedFor(sent, first.sessionId);
|
||||
expect(prepared?.revision).toBeGreaterThan(directIngress.revision);
|
||||
return prepared;
|
||||
});
|
||||
const selectedIngress = preparedFor(sent, first.sessionId)!;
|
||||
expect(
|
||||
sent
|
||||
.get(host.sessionId)
|
||||
?.some(
|
||||
(message) =>
|
||||
message.type === "selected-edge-turn" &&
|
||||
message.edgeKind === "host-sfu-ingress" &&
|
||||
message.revision === selectedIngress.revision,
|
||||
),
|
||||
).toBe(true);
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: selectedIngress.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(turnAdmission?.usage()).toEqual({ allocations: 1 }),
|
||||
);
|
||||
await router.close();
|
||||
});
|
||||
});
|
||||
@@ -1,487 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MediaRouteController,
|
||||
type PrepareMediaRouteInput,
|
||||
} from "../src/server/media-route-controller.ts";
|
||||
import type { ParticipantRouteAssignment } from "../src/shared/protocol.ts";
|
||||
|
||||
const HOST = "host_12345678";
|
||||
const DIRECT_ROOT = "viewer_direct_12345678";
|
||||
const SFU_ROOT_A = "viewer_sfu_a_12345678";
|
||||
const SFU_ROOT_B = "viewer_sfu_b_12345678";
|
||||
const THIRD_CHILD = "viewer_third_12345678";
|
||||
const SFU_ROOT_C = "viewer_sfu_c_12345678";
|
||||
const GENERATION_A = "generation_a_12345678";
|
||||
const GENERATION_B = "generation_b_12345678";
|
||||
|
||||
function assignment(
|
||||
upstream: ParticipantRouteAssignment["upstream"],
|
||||
childPeerIds: string[] = [],
|
||||
sfuPublicationGeneration: string | null = null,
|
||||
): ParticipantRouteAssignment {
|
||||
return { upstream, childPeerIds, sfuPublicationGeneration };
|
||||
}
|
||||
|
||||
function directAssignments(): Map<string, ParticipantRouteAssignment> {
|
||||
return new Map([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT, SFU_ROOT_A])],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_B, assignment({ kind: "none" })],
|
||||
]);
|
||||
}
|
||||
|
||||
function hybridPlan(
|
||||
generation = GENERATION_A,
|
||||
rootPeerId = SFU_ROOT_A,
|
||||
): PrepareMediaRouteInput {
|
||||
const otherRootPeerId =
|
||||
rootPeerId === SFU_ROOT_A ? SFU_ROOT_B : SFU_ROOT_A;
|
||||
return {
|
||||
assignments: new Map([
|
||||
[
|
||||
HOST,
|
||||
assignment({ kind: "none" }, [DIRECT_ROOT], generation),
|
||||
],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[rootPeerId, assignment({ kind: "sfu" })],
|
||||
[otherRootPeerId, assignment({ kind: "none" })],
|
||||
]),
|
||||
expectedParticipantIds: new Set([HOST, rootPeerId]),
|
||||
sfuPublicationGeneration: generation,
|
||||
sfuRootPeerIds: [rootPeerId],
|
||||
};
|
||||
}
|
||||
|
||||
function controller(
|
||||
assignments = directAssignments(),
|
||||
): MediaRouteController {
|
||||
return new MediaRouteController({ hostPeerId: HOST, assignments });
|
||||
}
|
||||
|
||||
function chainedAssignments(): Map<string, ParticipantRouteAssignment> {
|
||||
return new Map([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT])],
|
||||
[
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_A]),
|
||||
],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
[SFU_ROOT_B, assignment({ kind: "none" })],
|
||||
]);
|
||||
}
|
||||
|
||||
describe("MediaRouteController", () => {
|
||||
it("keeps SFU roots unique while charging one Host publication edge", () => {
|
||||
const assignments = new Map<string, ParticipantRouteAssignment>([
|
||||
[
|
||||
HOST,
|
||||
assignment({ kind: "none" }, [DIRECT_ROOT], GENERATION_A),
|
||||
],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_A, assignment({ kind: "sfu" })],
|
||||
[SFU_ROOT_B, assignment({ kind: "sfu" })],
|
||||
]);
|
||||
const routes = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A, SFU_ROOT_B],
|
||||
});
|
||||
|
||||
expect(routes.getActiveRoute().sfu.rootPeerIds).toEqual([
|
||||
SFU_ROOT_A,
|
||||
SFU_ROOT_B,
|
||||
]);
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
|
||||
const threeRootAssignments = new Map(assignments);
|
||||
threeRootAssignments.set(SFU_ROOT_C, assignment({ kind: "sfu" }));
|
||||
const threeRoots = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: threeRootAssignments,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A, SFU_ROOT_B, SFU_ROOT_C],
|
||||
});
|
||||
expect(threeRoots.getActiveRoute().sfu.rootPeerIds).toHaveLength(3);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A, SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("SFU root participants must be unique");
|
||||
|
||||
const excessiveHostEdges = new Map(assignments);
|
||||
excessiveHostEdges.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_C],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
excessiveHostEdges.set(
|
||||
SFU_ROOT_C,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
expect(
|
||||
() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: excessiveHostEdges,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A, SFU_ROOT_B],
|
||||
}),
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
});
|
||||
|
||||
it("atomically commits only after every expected participant is ready", () => {
|
||||
const routes = controller();
|
||||
const revision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(revision).toBe(1);
|
||||
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
|
||||
expect(routes.commit(revision)).toBe(false);
|
||||
expect(routes.getActiveRoute().revision).toBe(0);
|
||||
|
||||
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
|
||||
expect(routes.getActiveRoute().sfu.publicationGeneration).toBeNull();
|
||||
expect(routes.commit(revision)).toBe(true);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.getActiveRoute()).toMatchObject({
|
||||
revision,
|
||||
sfu: {
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
},
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("allows only one pending plan and ignores stale or wrong-phase messages", () => {
|
||||
const routes = controller();
|
||||
const revision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(routes.prepare(hybridPlan(GENERATION_B, SFU_ROOT_B))).toBeUndefined();
|
||||
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
|
||||
expect(routes.ready(HOST, revision, "active")).toBe(false);
|
||||
expect(routes.commit(revision - 1)).toBe(false);
|
||||
expect(routes.abort(revision - 1)).toBe(false);
|
||||
expect(routes.getPendingRoute()?.route.revision).toBe(revision);
|
||||
expect(routes.getActiveRoute().revision).toBe(0);
|
||||
});
|
||||
|
||||
it("aborts to the unchanged active topology with a newer revision", () => {
|
||||
const routes = controller();
|
||||
const before = routes.getActiveRoute();
|
||||
const preparedRevision = routes.prepare(hybridPlan())!;
|
||||
|
||||
expect(routes.abort(preparedRevision)).toBe(true);
|
||||
const after = routes.getActiveRoute();
|
||||
expect(after.revision).toBe(preparedRevision + 1);
|
||||
expect(after.assignments).toEqual(before.assignments);
|
||||
expect(after.sfu).toEqual(before.sfu);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.abort(preparedRevision)).toBe(false);
|
||||
|
||||
expect(routes.prepare(hybridPlan())).toBe(after.revision + 1);
|
||||
});
|
||||
|
||||
it("reconciles complete baseline snapshots without resetting revisions", () => {
|
||||
const routes = controller();
|
||||
const reconciledRevision = routes.reconcileBaseline({
|
||||
assignments: chainedAssignments(),
|
||||
});
|
||||
|
||||
expect(reconciledRevision).toBe(1);
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
expect(
|
||||
routes.getActiveRoute().assignments.get(SFU_ROOT_A)?.upstream,
|
||||
).toEqual({ kind: "peer", peerId: DIRECT_ROOT });
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: chainedAssignments() }),
|
||||
).toBeUndefined();
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
|
||||
const preparedRevision = routes.prepare(hybridPlan())!;
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: directAssignments() }),
|
||||
).toBeUndefined();
|
||||
expect(routes.getActiveRoute().revision).toBe(1);
|
||||
expect(routes.abort(preparedRevision)).toBe(true);
|
||||
expect(routes.getActiveRoute().revision).toBe(3);
|
||||
|
||||
expect(
|
||||
routes.reconcileBaseline({ assignments: directAssignments() }),
|
||||
).toBe(4);
|
||||
expect(routes.getActiveRoute().revision).toBe(4);
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves active SFU state while reconciling peer topology", () => {
|
||||
const activePlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
|
||||
const routes = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: activePlan.assignments,
|
||||
revision: 6,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
const assignments = new Map(activePlan.assignments);
|
||||
assignments.set(
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [SFU_ROOT_B]),
|
||||
);
|
||||
assignments.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: DIRECT_ROOT }),
|
||||
);
|
||||
|
||||
expect(routes.reconcileBaseline({ assignments })).toBe(7);
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("never overlaps old and new SFU publication generations", () => {
|
||||
const initialPlan = hybridPlan(GENERATION_A, SFU_ROOT_A);
|
||||
const routes = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: initialPlan.assignments,
|
||||
revision: 4,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
const preparedRevision = routes.prepare(
|
||||
hybridPlan(GENERATION_B, SFU_ROOT_B),
|
||||
)!;
|
||||
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_A,
|
||||
rootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
expect(routes.getPendingRoute()?.route.sfu).toEqual({
|
||||
publicationGeneration: GENERATION_B,
|
||||
rootPeerIds: [SFU_ROOT_B],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
|
||||
routes.ready(HOST, preparedRevision, "prepare");
|
||||
routes.ready(SFU_ROOT_B, preparedRevision, "prepare");
|
||||
expect(routes.commit(preparedRevision)).toBe(true);
|
||||
expect(routes.getActiveRoute().sfu).toEqual({
|
||||
publicationGeneration: GENERATION_B,
|
||||
rootPeerIds: [SFU_ROOT_B],
|
||||
});
|
||||
expect(routes.hostActiveMediaEdges()).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects host fanout above the configured two and inconsistent SFU ownership", () => {
|
||||
const tooManyHostEdges = new Map(hybridPlan().assignments);
|
||||
tooManyHostEdges.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_B],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
tooManyHostEdges.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: tooManyHostEdges,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
|
||||
const nonHostPublisher = directAssignments();
|
||||
nonHostPublisher.set(
|
||||
DIRECT_ROOT,
|
||||
assignment({ kind: "peer", peerId: HOST }, [], GENERATION_A),
|
||||
);
|
||||
expect(() => controller(nonHostPublisher)).toThrow(
|
||||
"Only the host may own an SFU publication generation",
|
||||
);
|
||||
|
||||
const missingRoot = hybridPlan().assignments;
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: missingRoot,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_B],
|
||||
}),
|
||||
).toThrow("Every SFU upstream must belong to the root allowlist");
|
||||
|
||||
const generationWithoutRoots = directAssignments();
|
||||
generationWithoutRoots.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_A],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: generationWithoutRoots,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
}),
|
||||
).toThrow("SFU roots and publication generation must be active together");
|
||||
});
|
||||
|
||||
it("applies capacity three uniformly to Host and Viewer endpoints", () => {
|
||||
const threeHostChildren = new Map<string, ParticipantRouteAssignment>([
|
||||
[
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, SFU_ROOT_A, THIRD_CHILD],
|
||||
),
|
||||
],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })],
|
||||
[THIRD_CHILD, assignment({ kind: "peer", peerId: HOST })],
|
||||
]);
|
||||
const hostAtCapacity = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: threeHostChildren,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
});
|
||||
expect(hostAtCapacity.hostActiveMediaEdges()).toBe(3);
|
||||
|
||||
const viewerWithTwoChildren = new Map<string, ParticipantRouteAssignment>([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT])],
|
||||
[
|
||||
DIRECT_ROOT,
|
||||
assignment(
|
||||
{ kind: "peer", peerId: HOST },
|
||||
[SFU_ROOT_A, SFU_ROOT_B],
|
||||
),
|
||||
],
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
[SFU_ROOT_B, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
]);
|
||||
expect(
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: viewerWithTwoChildren,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
}).getActiveRoute().assignments.get(DIRECT_ROOT)?.childPeerIds,
|
||||
).toHaveLength(2);
|
||||
|
||||
const publicationAtHostLimit = new Map<string, ParticipantRouteAssignment>([
|
||||
[
|
||||
HOST,
|
||||
assignment({ kind: "none" }, [DIRECT_ROOT], GENERATION_A),
|
||||
],
|
||||
[DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })],
|
||||
[SFU_ROOT_A, assignment({ kind: "sfu" })],
|
||||
]);
|
||||
const allowed = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: publicationAtHostLimit,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
expect(allowed.hostActiveMediaEdges()).toBe(2);
|
||||
|
||||
const publicationOverHostLimit = new Map(publicationAtHostLimit);
|
||||
publicationOverHostLimit.set(
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, THIRD_CHILD, SFU_ROOT_B],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
publicationOverHostLimit.set(
|
||||
THIRD_CHILD,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
publicationOverHostLimit.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: publicationOverHostLimit,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
});
|
||||
|
||||
it("rejects reciprocal peer cycles", () => {
|
||||
const assignments = new Map<string, ParticipantRouteAssignment>([
|
||||
[HOST, assignment({ kind: "none" })],
|
||||
[
|
||||
SFU_ROOT_A,
|
||||
assignment({ kind: "peer", peerId: SFU_ROOT_B }, [SFU_ROOT_B]),
|
||||
],
|
||||
[
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: SFU_ROOT_A }, [SFU_ROOT_A]),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(() => controller(assignments)).toThrow(
|
||||
"Peer media route contains a cycle",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves budgets and monotonic revisions across mixed sequences", () => {
|
||||
const routes = controller();
|
||||
let previousRevision = routes.getActiveRoute().revision;
|
||||
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const plan =
|
||||
index % 2 === 0
|
||||
? hybridPlan(GENERATION_A, SFU_ROOT_A)
|
||||
: {
|
||||
assignments: directAssignments(),
|
||||
expectedParticipantIds: new Set([HOST]),
|
||||
};
|
||||
const revision = routes.prepare(plan)!;
|
||||
expect(revision).toBeGreaterThan(previousRevision);
|
||||
expect(routes.prepare(plan)).toBeUndefined();
|
||||
expect(routes.ready(HOST, revision - 1, "prepare")).toBe(false);
|
||||
expect(routes.commit(revision - 1)).toBe(false);
|
||||
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
|
||||
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(true);
|
||||
expect(routes.ready(HOST, revision, "prepare")).toBe(false);
|
||||
if (index % 3 === 0) {
|
||||
expect(routes.abort(revision)).toBe(true);
|
||||
} else {
|
||||
if (plan.expectedParticipantIds.has(SFU_ROOT_A)) {
|
||||
expect(routes.ready(SFU_ROOT_A, revision, "prepare")).toBe(true);
|
||||
}
|
||||
expect(routes.commit(revision)).toBe(true);
|
||||
}
|
||||
|
||||
const active = routes.getActiveRoute();
|
||||
expect(active.revision).toBeGreaterThan(previousRevision);
|
||||
expect(routes.getPendingRoute()).toBeUndefined();
|
||||
expect(routes.hostActiveMediaEdges()).toBeLessThanOrEqual(2);
|
||||
previousRevision = active.revision;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,834 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PeerRelayTopology } from "../src/server/peer-relay-topology.ts";
|
||||
|
||||
function connected(...peerIds: string[]): Set<string> {
|
||||
return new Set(peerIds);
|
||||
}
|
||||
|
||||
function addRelayViewer(
|
||||
topology: PeerRelayTopology,
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
connectedPeerIds: ReadonlySet<string>,
|
||||
downstreamEdges: 1 | 2 | 3 = 1,
|
||||
) {
|
||||
const changes = topology.addViewer(roomId, peerId, connectedPeerIds);
|
||||
return [
|
||||
...changes,
|
||||
...topology.setViewerRelayCapacity(
|
||||
roomId,
|
||||
peerId,
|
||||
downstreamEdges,
|
||||
connectedPeerIds,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function expectValidTree(
|
||||
topology: PeerRelayTopology,
|
||||
roomId: string,
|
||||
hostPeerId: string,
|
||||
viewerPeerIds: readonly string[],
|
||||
endpointMediaCopyCapacity = 2,
|
||||
): number {
|
||||
const hostAssignment = topology.getAssignment(roomId, hostPeerId)!;
|
||||
expect(hostAssignment.parentPeerId).toBeNull();
|
||||
expect(hostAssignment.childPeerIds.length).toBeLessThanOrEqual(
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
|
||||
const allChildren = new Set(hostAssignment.childPeerIds);
|
||||
let maximumDepth = 0;
|
||||
for (const peerId of viewerPeerIds) {
|
||||
const assignment = topology.getAssignment(roomId, peerId)!;
|
||||
expect(assignment.childPeerIds.length).toBeLessThanOrEqual(
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
for (const childPeerId of assignment.childPeerIds) {
|
||||
expect(allChildren.has(childPeerId)).toBe(false);
|
||||
allChildren.add(childPeerId);
|
||||
}
|
||||
|
||||
let parentPeerId = assignment.parentPeerId;
|
||||
const ancestors = new Set([peerId]);
|
||||
let depth = 0;
|
||||
while (parentPeerId) {
|
||||
expect(ancestors.has(parentPeerId)).toBe(false);
|
||||
ancestors.add(parentPeerId);
|
||||
depth += 1;
|
||||
parentPeerId = topology.getAssignment(roomId, parentPeerId)?.parentPeerId ?? null;
|
||||
}
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
}
|
||||
expect(allChildren).toEqual(new Set(viewerPeerIds));
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
describe("PeerRelayTopology", () => {
|
||||
it("keeps viewers as leaves until a relay slot is explicitly advertised", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBeNull();
|
||||
expect(
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
|
||||
).toEqual([]);
|
||||
|
||||
const changes = topology.setViewerRelayCapacity(
|
||||
"room",
|
||||
"viewer-a",
|
||||
1,
|
||||
peers,
|
||||
);
|
||||
expect(changes.map(({ peerId }) => peerId).sort()).toEqual([
|
||||
"viewer-a",
|
||||
"viewer-c",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "viewer-a")).toEqual({
|
||||
parentPeerId: "host",
|
||||
childPeerIds: ["viewer-c"],
|
||||
});
|
||||
expect(topology.getAssignment("room", "viewer-b")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
});
|
||||
|
||||
it("deterministically promotes an unassigned relay over the oldest zero-capacity host leaf", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
|
||||
topology.setHost("room", "host", peers);
|
||||
for (const peerId of ["viewer-a", "viewer-b", "viewer-c"]) {
|
||||
topology.addViewer("room", peerId, peers);
|
||||
}
|
||||
|
||||
const changes = topology.setViewerRelayCapacity(
|
||||
"room",
|
||||
"viewer-c",
|
||||
1,
|
||||
peers,
|
||||
{ rescueUnassignedRelay: true },
|
||||
);
|
||||
|
||||
expect(changes.map(({ peerId }) => peerId)).toEqual([
|
||||
"host",
|
||||
"viewer-a",
|
||||
"viewer-c",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "host")).toEqual({
|
||||
parentPeerId: null,
|
||||
childPeerIds: ["viewer-b", "viewer-c"],
|
||||
});
|
||||
expect(topology.getAssignment("room", "viewer-c")).toEqual({
|
||||
parentPeerId: "host",
|
||||
childPeerIds: ["viewer-a"],
|
||||
});
|
||||
expect(topology.getAssignment("room", "viewer-a")).toEqual({
|
||||
parentPeerId: "viewer-c",
|
||||
childPeerIds: [],
|
||||
});
|
||||
expect(
|
||||
expectValidTree(
|
||||
topology,
|
||||
"room",
|
||||
"host",
|
||||
["viewer-a", "viewer-b", "viewer-c"],
|
||||
),
|
||||
).toBe(2);
|
||||
|
||||
const afterRescue = topology.getAssignments("room");
|
||||
topology.setViewerRelayCapacity("room", "viewer-c", 0, peers);
|
||||
expect(
|
||||
topology.setViewerRelayCapacity(
|
||||
"room",
|
||||
"viewer-c",
|
||||
1,
|
||||
peers,
|
||||
{ rescueUnassignedRelay: true },
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(topology.getAssignments("room")).toEqual(afterRescue);
|
||||
});
|
||||
|
||||
it("does not rescue admission through a host child that already owns a subtree", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "a", "b", "c", "d", "candidate");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "a", peers);
|
||||
topology.addViewer("room", "b", peers);
|
||||
topology.setViewerRelayCapacity("room", "a", 1, peers);
|
||||
topology.setViewerRelayCapacity("room", "b", 1, peers);
|
||||
topology.addViewer("room", "c", peers);
|
||||
topology.addViewer("room", "d", peers);
|
||||
topology.addViewer("room", "candidate", peers);
|
||||
const before = topology.getAssignments("room");
|
||||
|
||||
expect(
|
||||
topology.setViewerRelayCapacity(
|
||||
"room",
|
||||
"candidate",
|
||||
1,
|
||||
peers,
|
||||
{ rescueUnassignedRelay: true },
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not migrate healthy edges when a relay withdraws capacity", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 0, peers),
|
||||
).toEqual([]);
|
||||
topology.addViewer("room", "viewer-d", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
|
||||
});
|
||||
|
||||
it("applies one deployment capacity to Host and Viewer endpoints", () => {
|
||||
const peers = connected("host", "relay");
|
||||
const defaultTopology = new PeerRelayTopology();
|
||||
defaultTopology.setHost("default", "host", peers);
|
||||
defaultTopology.addViewer("default", "relay", peers);
|
||||
defaultTopology.setViewerRelayCapacity("default", "relay", 3, peers);
|
||||
expect(
|
||||
defaultTopology.getAdvertisedDownstreamCapacity("default", "host"),
|
||||
).toBe(2);
|
||||
expect(
|
||||
defaultTopology.getAdvertisedDownstreamCapacity("default", "relay"),
|
||||
).toBe(2);
|
||||
|
||||
const expandedTopology = new PeerRelayTopology(3);
|
||||
expandedTopology.setHost("expanded", "host", peers);
|
||||
expandedTopology.addViewer("expanded", "relay", peers);
|
||||
expandedTopology.setViewerRelayCapacity("expanded", "relay", 3, peers);
|
||||
expect(
|
||||
expandedTopology.getAdvertisedDownstreamCapacity("expanded", "host"),
|
||||
).toBe(3);
|
||||
expect(
|
||||
expandedTopology.getAdvertisedDownstreamCapacity("expanded", "relay"),
|
||||
).toBe(3);
|
||||
|
||||
const tightenedTopology = new PeerRelayTopology(1);
|
||||
tightenedTopology.setHost("tightened", "host", peers);
|
||||
tightenedTopology.addViewer("tightened", "relay", peers);
|
||||
tightenedTopology.setViewerRelayCapacity("tightened", "relay", 3, peers);
|
||||
expect(
|
||||
tightenedTopology.getAdvertisedDownstreamCapacity("tightened", "host"),
|
||||
).toBe(1);
|
||||
expect(
|
||||
tightenedTopology.getAdvertisedDownstreamCapacity("tightened", "relay"),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not reassign a failed edge through a viewer leaf", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"viewer-c",
|
||||
peers,
|
||||
new Set(["viewer-a"]),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a reconnecting viewer to advertise capacity again", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-b", peers);
|
||||
topology.setViewerRelayCapacity("room", "viewer-a", 1, peers);
|
||||
topology.addViewer("room", "viewer-c", peers);
|
||||
|
||||
topology.addViewer("room", "viewer-a", peers);
|
||||
topology.addViewer("room", "viewer-d", peers);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
|
||||
});
|
||||
|
||||
it("fills the shallowest stable slots with bounded fanout", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
const host = "host";
|
||||
topology.setHost(roomId, host, connected(host));
|
||||
|
||||
const viewers = ["viewer-a", "viewer-b", "viewer-c", "viewer-d", "viewer-e"];
|
||||
for (const viewer of viewers) {
|
||||
addRelayViewer(topology, roomId, viewer, connected(host, ...viewers));
|
||||
}
|
||||
|
||||
expect(topology.getAssignment(roomId, host)).toEqual({
|
||||
parentPeerId: null,
|
||||
childPeerIds: ["viewer-a", "viewer-b"],
|
||||
});
|
||||
expect(topology.getAssignment(roomId, "viewer-a")).toEqual({
|
||||
parentPeerId: host,
|
||||
childPeerIds: ["viewer-c"],
|
||||
});
|
||||
expect(topology.getAssignment(roomId, "viewer-b")).toEqual({
|
||||
parentPeerId: host,
|
||||
childPeerIds: ["viewer-d"],
|
||||
});
|
||||
expect(topology.getAssignment(roomId, "viewer-c")).toEqual({
|
||||
parentPeerId: "viewer-a",
|
||||
childPeerIds: ["viewer-e"],
|
||||
});
|
||||
});
|
||||
|
||||
it("forms two bounded acyclic chains for eight viewers", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
const hostPeerId = "host";
|
||||
const viewerPeerIds = Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => `viewer-${index + 1}`,
|
||||
);
|
||||
topology.setHost(roomId, hostPeerId, connected(hostPeerId));
|
||||
for (const peerId of viewerPeerIds) {
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
peerId,
|
||||
connected(hostPeerId, ...viewerPeerIds),
|
||||
);
|
||||
}
|
||||
|
||||
expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4);
|
||||
});
|
||||
|
||||
it("uses capacity three uniformly in an eight-viewer tree", () => {
|
||||
const topology = new PeerRelayTopology(3);
|
||||
const roomId = "room";
|
||||
const hostPeerId = "host";
|
||||
const viewerPeerIds = Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => `viewer-${index + 1}`,
|
||||
);
|
||||
const peers = connected(hostPeerId, ...viewerPeerIds);
|
||||
topology.setHost(roomId, hostPeerId, peers);
|
||||
|
||||
for (let index = 0; index < viewerPeerIds.length; index += 1) {
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
viewerPeerIds[index]!,
|
||||
peers,
|
||||
3,
|
||||
);
|
||||
expectValidTree(
|
||||
topology,
|
||||
roomId,
|
||||
hostPeerId,
|
||||
viewerPeerIds.slice(0, index + 1),
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-1")?.childPeerIds).toEqual([
|
||||
"viewer-4",
|
||||
"viewer-5",
|
||||
"viewer-6",
|
||||
]);
|
||||
expect(topology.getDownstreamCapacity(roomId, "viewer-1")).toBe(3);
|
||||
expect(topology.getAssignment(roomId, "viewer-5")?.parentPeerId).toBe(
|
||||
"viewer-1",
|
||||
);
|
||||
expect(
|
||||
expectValidTree(topology, roomId, hostPeerId, viewerPeerIds, 3),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("reattaches relay children within the shared endpoint capacity", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "relay", "sibling", "child-a", "child-b");
|
||||
topology.setHost("room", "host", peers);
|
||||
addRelayViewer(topology, "room", "relay", peers, 2);
|
||||
addRelayViewer(topology, "room", "sibling", peers, 2);
|
||||
addRelayViewer(topology, "room", "child-a", peers, 2);
|
||||
addRelayViewer(topology, "room", "child-b", peers, 2);
|
||||
expect(topology.getAssignment("room", "relay")?.childPeerIds).toEqual([
|
||||
"child-a",
|
||||
"child-b",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "sibling")?.childPeerIds).toEqual([]);
|
||||
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
"relay",
|
||||
connected("host", "sibling", "child-a", "child-b"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment("room", "child-a")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
expect(topology.getAssignment("room", "child-b")?.parentPeerId).toBe(
|
||||
"sibling",
|
||||
);
|
||||
expect(
|
||||
expectValidTree(
|
||||
topology,
|
||||
"room",
|
||||
"host",
|
||||
["sibling", "child-a", "child-b"],
|
||||
2,
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the same depth-four bound for initial admission", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
const peerIds = [
|
||||
"host",
|
||||
"viewer-a",
|
||||
"viewer-b",
|
||||
"viewer-c",
|
||||
"viewer-d",
|
||||
"viewer-e",
|
||||
"viewer-f",
|
||||
];
|
||||
const peers = connected(...peerIds);
|
||||
topology.setHost(roomId, "host", peers);
|
||||
topology.addViewer(roomId, "viewer-a", peers);
|
||||
topology.setViewerRelayCapacity(roomId, "viewer-a", 1, peers);
|
||||
topology.addViewer(roomId, "viewer-b", peers);
|
||||
for (const peerId of ["viewer-c", "viewer-d", "viewer-e"]) {
|
||||
topology.addViewer(roomId, peerId, peers);
|
||||
topology.setViewerRelayCapacity(roomId, peerId, 1, peers);
|
||||
}
|
||||
topology.addViewer(roomId, "viewer-f", peers);
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-e")?.parentPeerId).toBe(
|
||||
"viewer-d",
|
||||
);
|
||||
expect(topology.getAssignment(roomId, "viewer-f")?.parentPeerId).toBeNull();
|
||||
expect(
|
||||
expectValidTree(
|
||||
topology,
|
||||
roomId,
|
||||
"host",
|
||||
peerIds.slice(1, -1),
|
||||
),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("leaves a disconnected viewer subtree sticky during its grace window", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-d",
|
||||
connected("host", "viewer-b", "viewer-c", "viewer-d"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
expect(topology.getAssignment(roomId, "viewer-d")?.parentPeerId).toBe(
|
||||
"viewer-b",
|
||||
);
|
||||
});
|
||||
|
||||
it("reattaches only the removed viewer's direct child subtree entry", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-d",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-e",
|
||||
connected(
|
||||
"host",
|
||||
"viewer-a",
|
||||
"viewer-b",
|
||||
"viewer-c",
|
||||
"viewer-d",
|
||||
"viewer-e",
|
||||
),
|
||||
);
|
||||
|
||||
const changes = topology.removeViewer(
|
||||
roomId,
|
||||
"viewer-a",
|
||||
connected("host", "viewer-b", "viewer-c", "viewer-d", "viewer-e"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment(roomId, "host")?.childPeerIds).toEqual([
|
||||
"viewer-b",
|
||||
"viewer-c",
|
||||
]);
|
||||
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
expect(topology.getAssignment(roomId, "viewer-e")?.parentPeerId).toBe(
|
||||
"viewer-c",
|
||||
);
|
||||
expect(changes.map((change) => change.peerId).sort()).toEqual([
|
||||
"host",
|
||||
"viewer-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits to reattach an offline subtree root until it reconnects", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
addRelayViewer(topology, roomId, "viewer-a", connected("host", "viewer-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-b",
|
||||
connected("host", "viewer-a", "viewer-b"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
topology.removeViewer(
|
||||
roomId,
|
||||
"viewer-a",
|
||||
connected("host", "viewer-b"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBeNull();
|
||||
expect(topology.getAssignment(roomId, "host")?.childPeerIds).toEqual([
|
||||
"viewer-b",
|
||||
]);
|
||||
|
||||
const reconnectChanges = addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
"viewer-c",
|
||||
connected("host", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
expect(reconnectChanges.map((change) => change.peerId).sort()).toEqual([
|
||||
"host",
|
||||
"viewer-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns connected pending viewers when removals make slots reachable", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const roomId = "room";
|
||||
topology.setHost(roomId, "host", connected("host"));
|
||||
for (const peerId of ["a", "b", "c", "d"]) {
|
||||
addRelayViewer(
|
||||
topology,
|
||||
roomId,
|
||||
peerId,
|
||||
connected("host", "a", "b", "c", "d"),
|
||||
);
|
||||
}
|
||||
|
||||
addRelayViewer(topology, roomId, "e", connected("host", "c", "d", "e"));
|
||||
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBeNull();
|
||||
|
||||
topology.removeViewer(roomId, "a", connected("host", "c", "d", "e"));
|
||||
topology.removeViewer(roomId, "b", connected("host", "c", "d", "e"));
|
||||
|
||||
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBe("c");
|
||||
expect(topology.getAssignment(roomId, "c")?.childPeerIds).toEqual(["e"]);
|
||||
});
|
||||
|
||||
it("keeps pending viewers unassigned until a host arrives", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
addRelayViewer(topology, "room", "viewer-a", connected("viewer-a"));
|
||||
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBeNull();
|
||||
|
||||
topology.setHost(
|
||||
"room",
|
||||
"host",
|
||||
connected("host", "viewer-a"),
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBe(
|
||||
"host",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates only direct root parents when the host identity changes", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host-a", connected("host-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-a",
|
||||
connected("host-a", "viewer-a"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-b",
|
||||
connected("host-a", "viewer-a", "viewer-b"),
|
||||
);
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-c",
|
||||
connected("host-a", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
topology.setHost(
|
||||
"room",
|
||||
"host-b",
|
||||
connected("host-b", "viewer-a", "viewer-b", "viewer-c"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBe(
|
||||
"host-b",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-b")?.parentPeerId).toBe(
|
||||
"host-b",
|
||||
);
|
||||
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
|
||||
"viewer-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("reassigns a failed edge deterministically without entering its subtree", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = ["host", "a", "b", "c", "d", "e"];
|
||||
topology.setHost("room", "host", connected(...peers));
|
||||
for (const peerId of peers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...peers));
|
||||
}
|
||||
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("c");
|
||||
const changes = topology.reassignViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected(...peers),
|
||||
new Set(["c"]),
|
||||
4,
|
||||
);
|
||||
|
||||
expect(changes?.map(({ peerId }) => peerId).sort()).toEqual([
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("d");
|
||||
expect(expectValidTree(topology, "room", "host", peers.slice(1))).toBe(3);
|
||||
});
|
||||
|
||||
it("plans a bounded reparent without mutating until the exact parent commits", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = ["host", "a", "b", "c", "d", "e"];
|
||||
topology.setHost("room", "host", connected(...peers));
|
||||
for (const peerId of peers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...peers));
|
||||
}
|
||||
const before = topology.getAssignments("room");
|
||||
const excluded = new Set(["c"]);
|
||||
|
||||
expect(
|
||||
topology.findViewerReassignmentParent(
|
||||
"room",
|
||||
"e",
|
||||
connected(...peers),
|
||||
excluded,
|
||||
4,
|
||||
),
|
||||
).toBe("d");
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected(...peers),
|
||||
excluded,
|
||||
4,
|
||||
"b",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected(...peers),
|
||||
excluded,
|
||||
4,
|
||||
"d",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBe("d");
|
||||
});
|
||||
|
||||
it("does not mutate when every bounded reparent candidate is excluded", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host", connected("host", "a", "b", "c"));
|
||||
for (const peerId of ["a", "b", "c"]) {
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
peerId,
|
||||
connected("host", "a", "b", "c"),
|
||||
);
|
||||
}
|
||||
const before = topology.getAssignments("room");
|
||||
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"c",
|
||||
connected("host", "a", "b", "c"),
|
||||
new Set(["host", "a", "b"]),
|
||||
4,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
});
|
||||
|
||||
it("rejects a reparent whose deepest descendant would exceed max depth", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
topology.setHost("room", "host", connected(...peers));
|
||||
for (const peerId of peers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...peers));
|
||||
}
|
||||
const before = topology.getAssignments("room");
|
||||
|
||||
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
|
||||
expect(
|
||||
topology.reassignViewer(
|
||||
"room",
|
||||
"g",
|
||||
connected(...peers),
|
||||
new Set(["e"]),
|
||||
4,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(topology.getAssignments("room")).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not attach a pending subtree beyond the initial-admission depth bound", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const initialPeers = ["host", "a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
topology.setHost("room", "host", connected(...initialPeers));
|
||||
for (const peerId of initialPeers.slice(1)) {
|
||||
addRelayViewer(topology, "room", peerId, connected(...initialPeers));
|
||||
}
|
||||
expect(topology.getAssignment("room", "e")).toMatchObject({
|
||||
parentPeerId: "c",
|
||||
childPeerIds: ["g"],
|
||||
});
|
||||
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
"c",
|
||||
connected("host", "a", "b", "d", "f", "h"),
|
||||
);
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
"h",
|
||||
connected("host", "a", "b", "d", "f"),
|
||||
);
|
||||
topology.addViewer(
|
||||
"room",
|
||||
"x",
|
||||
connected("host", "a", "b", "d", "f", "x"),
|
||||
);
|
||||
|
||||
topology.addViewer(
|
||||
"room",
|
||||
"e",
|
||||
connected("host", "a", "b", "d", "e", "f", "g", "x"),
|
||||
);
|
||||
|
||||
expect(topology.getAssignment("room", "e")?.parentPeerId).toBeNull();
|
||||
expect(topology.getAssignment("room", "g")?.parentPeerId).toBe("e");
|
||||
expect(topology.getAssignment("room", "f")?.childPeerIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops all assignments when a room is deleted", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
topology.setHost("room", "host-a", connected("host-a"));
|
||||
addRelayViewer(
|
||||
topology,
|
||||
"room",
|
||||
"viewer-a",
|
||||
connected("host-a", "viewer-a"),
|
||||
);
|
||||
|
||||
topology.deleteRoom("room");
|
||||
expect(topology.getAssignment("room", "host-a")).toBeUndefined();
|
||||
expect(topology.getAssignment("room", "viewer-a")).toBeUndefined();
|
||||
|
||||
topology.setHost("room", "host-b", connected("host-b"));
|
||||
expect(topology.getAssignment("room", "host-b")).toEqual({
|
||||
parentPeerId: null,
|
||||
childPeerIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-17
@@ -76,7 +76,7 @@ const parentEdgeQualityEvidence = {
|
||||
} as const;
|
||||
|
||||
describe("client signaling protocol", () => {
|
||||
it("keeps the Native sender on the same signaling version", () => {
|
||||
it("keeps executable senders outside the Browser v7 release", () => {
|
||||
const nativeWire = readFileSync(
|
||||
join(
|
||||
import.meta.dirname,
|
||||
@@ -85,9 +85,8 @@ describe("client signaling protocol", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(nativeWire).toMatch(
|
||||
new RegExp(`signalingProtocol\\s*=\\s*"${SIGNALING_PROTOCOL}"`),
|
||||
);
|
||||
expect(SIGNALING_PROTOCOL).toBe("screener-v7");
|
||||
expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/);
|
||||
});
|
||||
|
||||
it("keeps signaling challenges strict and sequence-only", () => {
|
||||
@@ -730,13 +729,7 @@ describe("client signaling protocol", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts bounded route acknowledgements, failures, and SFU recovery events", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "sfu-reselection-ready",
|
||||
revision: 7,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
it("accepts bounded route acknowledgements and failures", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "route-ready",
|
||||
@@ -791,12 +784,6 @@ describe("client signaling protocol", () => {
|
||||
revision: 1.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "sfu-reselection-ready",
|
||||
revision: -1,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1107,6 +1094,11 @@ describe("server signaling protocol", () => {
|
||||
revision: 9,
|
||||
phase: "prepare",
|
||||
assignment,
|
||||
candidate: {
|
||||
childPeerId: "child_12345678",
|
||||
connectionId: "connection_12345678",
|
||||
transport: "direct",
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -120,6 +120,34 @@ function peerEdge(
|
||||
}
|
||||
|
||||
describe("RoomRouteController", () => {
|
||||
it.each([1, 2, 3] as const)(
|
||||
"admits 20 Viewers through one bounded event-driven graph at C=%i",
|
||||
(capacity) => {
|
||||
const routes = controller(capacity);
|
||||
const viewerPeerIds = Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) => `viewer_${String(index).padStart(2, "0")}_12345678`,
|
||||
);
|
||||
viewerPeerIds.forEach((peerId, index) => {
|
||||
addViewer(routes, peerId, capacity);
|
||||
const operation = routes.reconcile(index * 2).operation!;
|
||||
expect(operation.childPeerId).toBe(peerId);
|
||||
commitCurrent(routes, index * 2 + 1, `${peerId}_connection`);
|
||||
});
|
||||
const snapshot = routes.snapshot();
|
||||
expect(snapshot.upstreamByViewer).toHaveLength(20);
|
||||
for (const parentPeerId of [HOST, ...viewerPeerIds]) {
|
||||
const childCount = [...snapshot.upstreamByViewer.values()].filter(
|
||||
(edge) =>
|
||||
edge.kind === "peer" &&
|
||||
edge.physicalActive &&
|
||||
edge.parentPeerId === parentPeerId,
|
||||
).length;
|
||||
expect(childCount).toBeLessThanOrEqual(capacity);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2, 3] as const)(
|
||||
"builds a deterministic acyclic route without a depth cap at C=%i",
|
||||
(capacity) => {
|
||||
@@ -217,6 +245,7 @@ describe("RoomRouteController", () => {
|
||||
connectionId: "candidate_1",
|
||||
}, 1_001);
|
||||
expect(failed.accepted).toBe(true);
|
||||
expect(failed.activeRevision).toBeGreaterThan(first.current!.revision);
|
||||
expect(failed.exhausted).toBeUndefined();
|
||||
expect(failed.released).toEqual([]);
|
||||
expect(routes.snapshot().operation).toMatchObject({
|
||||
@@ -444,6 +473,7 @@ describe("RoomRouteController", () => {
|
||||
generation: "publication_1",
|
||||
connectionId: "publication:publication_1",
|
||||
})).toBe(true);
|
||||
routes.touchExternalFacts();
|
||||
const repair = routes.reconcile(2).operation!;
|
||||
expect(repair.childPeerId).toBe(A);
|
||||
expect(repair.candidates[0]?.tuple).toEqual({
|
||||
@@ -487,11 +517,12 @@ describe("RoomRouteController", () => {
|
||||
connectionId: "a_sfu_2",
|
||||
}, 4);
|
||||
expect(repaired.accepted).toBe(true);
|
||||
expect(repaired.released).toEqual([
|
||||
expect(repaired.released).toHaveLength(3);
|
||||
expect(repaired.released).toEqual(expect.arrayContaining([
|
||||
"publication_overlap",
|
||||
"a_subscription",
|
||||
"publication_resource",
|
||||
]);
|
||||
]));
|
||||
expect(routes.snapshot().hostPublication).toMatchObject({
|
||||
generation: "publication_2",
|
||||
usable: true,
|
||||
@@ -599,6 +630,7 @@ describe("RoomRouteController", () => {
|
||||
generation: "publication_1",
|
||||
connectionId: "publication:publication_1",
|
||||
})).toBe(true);
|
||||
routes.touchExternalFacts();
|
||||
const operation = routes.reconcile(0).operation!;
|
||||
expect(operation.candidates[0]).toMatchObject({
|
||||
tuple: { kind: "sfu", publication: "replace", ingress: "direct" },
|
||||
@@ -623,12 +655,13 @@ describe("RoomRouteController", () => {
|
||||
connectionId: "a_sfu_2",
|
||||
}, 2);
|
||||
expect(settled.accepted).toBe(true);
|
||||
expect(settled.released).toEqual([
|
||||
expect(settled.released).toHaveLength(4);
|
||||
expect(settled.released).toEqual(expect.arrayContaining([
|
||||
"publication_overlap",
|
||||
"subscription_a",
|
||||
"subscription_b",
|
||||
"publication_resource_1",
|
||||
]);
|
||||
]));
|
||||
expect(routes.snapshot().upstreamByViewer.get(A)).toMatchObject({
|
||||
publicationGeneration: "publication_2",
|
||||
});
|
||||
@@ -902,4 +935,77 @@ describe("RoomRouteController", () => {
|
||||
resource: "b_turn_resource",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries an exact failed tuple only after a new external fact", () => {
|
||||
const routes = controller(1);
|
||||
addViewer(routes, A, 0);
|
||||
routes.hydrateEdge(A, peerEdge(HOST, "failed_direct"));
|
||||
expect(routes.invalidateEdge({
|
||||
childPeerId: A,
|
||||
childSessionId: `${A}_session`,
|
||||
parentSessionId: "host_session",
|
||||
routeRevision: 0,
|
||||
connectionId: "failed_direct",
|
||||
})).toBe(true);
|
||||
expect(routes.reconcile(0).operation).toBeUndefined();
|
||||
expect(routes.snapshot().upstreamByViewer.get(A)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
|
||||
routes.touchExternalFacts();
|
||||
expect(routes.reconcile(1).operation?.candidates[0]?.tuple).toEqual({
|
||||
kind: "peer",
|
||||
parentPeerId: HOST,
|
||||
transport: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends one ordinary restore candidate after a healthy bounded gap", () => {
|
||||
const routes = controller(3, { sfuEnabled: true });
|
||||
for (const [peerId, connectionId] of [
|
||||
[A, "a_direct"],
|
||||
[B, "b_direct"],
|
||||
[C, "c_direct"],
|
||||
] as const) {
|
||||
addViewer(routes, peerId, 0);
|
||||
routes.hydrateEdge(peerId, peerEdge(HOST, connectionId));
|
||||
}
|
||||
addViewer(routes, D, 0);
|
||||
const operation = routes.reconcile(0).operation!;
|
||||
expect(operation.childPeerId).toBe(C);
|
||||
expect(operation.candidates[0]?.endpointTransition.kind).toBe("bounded-gap");
|
||||
const gap = routes.retireCurrentCandidateProducer(cursorGuard(operation), 1);
|
||||
expect(gap.accepted).toBe(true);
|
||||
const tuples = routes.snapshot().operation?.candidates.map((plan) => plan.tuple);
|
||||
expect(tuples?.at(-1)).toEqual({
|
||||
kind: "peer",
|
||||
parentPeerId: HOST,
|
||||
transport: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote when synchronous admission commit fails", () => {
|
||||
const routes = controller(1);
|
||||
addViewer(routes, A, 0);
|
||||
const operation = routes.reconcile(0).operation!;
|
||||
const prepared = beginCandidate(routes, {
|
||||
nowMs: 1,
|
||||
connectionId: "candidate_connection",
|
||||
reservation: { kind: "direct" },
|
||||
}).operation!;
|
||||
const settled = routes.candidateReady(
|
||||
{
|
||||
childPeerId: operation.childPeerId,
|
||||
childSessionId: operation.childSessionId,
|
||||
revision: prepared.current!.revision,
|
||||
connectionId: "candidate_connection",
|
||||
},
|
||||
2,
|
||||
() => false,
|
||||
);
|
||||
expect(settled.accepted).toBe(false);
|
||||
expect(settled.activeRevision).toBeGreaterThan(prepared.current!.revision);
|
||||
expect(routes.snapshot().upstreamByViewer.has(A)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -72,9 +72,16 @@ function createFakePublisher(log: string[], label: string) {
|
||||
}
|
||||
|
||||
type SubscriberEvents = Parameters<
|
||||
NonNullable<ConstructorParameters<typeof ViewerSfuRoute>[0]["createSubscriber"]>
|
||||
NonNullable<ConstructorParameters<typeof ViewerSfuRoute>[1]["createSubscriber"]>
|
||||
>[0];
|
||||
|
||||
const candidate = (
|
||||
revision: number,
|
||||
childPeerId = "viewer_12345678",
|
||||
transport: "direct" | "selected-turn" | "sfu" = "direct",
|
||||
connectionId = `candidate_${revision}_12345678`,
|
||||
) => ({ childPeerId, connectionId, transport });
|
||||
|
||||
function createFakeSubscriber(events: SubscriberEvents, log: string[], label: string) {
|
||||
return {
|
||||
events,
|
||||
@@ -106,7 +113,12 @@ describe("minimal route transition contracts", () => {
|
||||
const oldToken = route.token()!;
|
||||
expect(route.markMediaActive(oldToken)).toBe(true);
|
||||
|
||||
route.accept({ revision: 2, phase: "prepare", assignment: nextAssignment });
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: nextAssignment,
|
||||
candidate: candidate(2),
|
||||
});
|
||||
expect(route.getMediaAssignment()).toEqual(oldAssignment);
|
||||
expect(route.markMediaActive(route.token()!)).toBe(false);
|
||||
|
||||
@@ -121,7 +133,7 @@ describe("minimal route transition contracts", () => {
|
||||
it("keeps duplicate peer prepare idempotent and releases it before takeover", async () => {
|
||||
const prepared: Array<{ parentPeerId: string | null; revision?: number }> = [];
|
||||
const activatePeer = vi.fn(() => true);
|
||||
const route = new ViewerSfuRoute({
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer,
|
||||
preparePeer: (assignment, revision) =>
|
||||
prepared.push({
|
||||
@@ -147,6 +159,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: nextAssignment,
|
||||
candidate: candidate(2),
|
||||
}),
|
||||
).toBe("accepted");
|
||||
expect(prepared).toEqual([
|
||||
@@ -158,6 +171,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: nextAssignment,
|
||||
candidate: candidate(2),
|
||||
}),
|
||||
).toBe("duplicate");
|
||||
expect(prepared).toEqual([
|
||||
@@ -168,6 +182,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 3,
|
||||
phase: "prepare",
|
||||
assignment: peerAssignment("third-parent"),
|
||||
candidate: candidate(3),
|
||||
});
|
||||
expect(prepared.slice(-2)).toEqual([
|
||||
{ parentPeerId: null },
|
||||
@@ -178,6 +193,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 4,
|
||||
phase: "prepare",
|
||||
assignment: viewerSfuAssignment(),
|
||||
candidate: candidate(4, "viewer_12345678", "sfu"),
|
||||
});
|
||||
expect(prepared.at(-1)).toEqual({ parentPeerId: null });
|
||||
});
|
||||
@@ -257,6 +273,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("publication-b"),
|
||||
candidate: candidate(2, "viewer_12345678", "sfu"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
expect(publishers[1]?.activate).toHaveBeenCalledOnce();
|
||||
@@ -291,8 +308,13 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("publication-b"),
|
||||
candidate: candidate(
|
||||
2,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"selected-connection",
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
expect(
|
||||
route.startSelectedEdgeTurn({
|
||||
type: "selected-edge-turn",
|
||||
@@ -310,10 +332,10 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
|
||||
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
||||
expect(publishers[0]?.disconnect).toHaveBeenCalled();
|
||||
expect(publishers[1]?.connect).toHaveBeenCalledWith({
|
||||
await vi.waitFor(() => expect(publishers).toHaveLength(1));
|
||||
expect(publishers[0]?.connect).toHaveBeenCalledWith({
|
||||
url: "wss://sfu.example.test",
|
||||
token: "token-2",
|
||||
rtcConfig: {
|
||||
@@ -334,7 +356,7 @@ describe("minimal route transition contracts", () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const streams: MediaStream[] = [];
|
||||
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
||||
const route = new ViewerSfuRoute({
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => true,
|
||||
reconcileSfuChildren: () => undefined,
|
||||
onSfuStream: (stream) => streams.push(stream),
|
||||
@@ -370,6 +392,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: viewerSfuAssignment(),
|
||||
candidate: candidate(2, "viewer_12345678", "sfu"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
const pendingStream = {} as MediaStream;
|
||||
@@ -403,7 +426,7 @@ describe("minimal route transition contracts", () => {
|
||||
it("discards a paused pending subscriber and ignores its later evidence", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
||||
const route = new ViewerSfuRoute({
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => true,
|
||||
reconcileSfuChildren: () => undefined,
|
||||
onSfuStream: () => undefined,
|
||||
@@ -421,6 +444,7 @@ describe("minimal route transition contracts", () => {
|
||||
revision: 3,
|
||||
phase: "prepare",
|
||||
assignment: viewerSfuAssignment(),
|
||||
candidate: candidate(3, "viewer_12345678", "sfu"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(3));
|
||||
route.setPaused(true);
|
||||
|
||||
+25
-8885
File diff suppressed because it is too large.
Load diff
@@ -86,6 +86,19 @@ describe("SfuResourceAdmission", () => {
|
||||
expect(admission.beginDrain(stale)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not commit a publication without its first exact subscription", () => {
|
||||
const admission = new SfuResourceAdmission({
|
||||
ingressCapacity: 1,
|
||||
egressCapacity: 1,
|
||||
});
|
||||
const pending = publication("1", "publication_pending");
|
||||
expect(admission.reservePublication(pending)).toBe(true);
|
||||
expect(admission.commitPublication(pending)).toBeNull();
|
||||
expect(admission.beginDrain(pending)).toBe(true);
|
||||
expect(admission.completeDrain(pending)).toBe(true);
|
||||
expect(admission.usage()).toEqual({ ingress: 0, egress: 0 });
|
||||
});
|
||||
|
||||
it("commits a new publication and its subscriptions before draining the old generation", () => {
|
||||
const admission = new SfuResourceAdmission({
|
||||
ingressCapacity: 2,
|
||||
@@ -218,21 +231,4 @@ describe("SfuResourceAdmission", () => {
|
||||
expect(admission.usage()).toEqual({ ingress: 0, egress: 0 });
|
||||
});
|
||||
|
||||
it("keeps the aggregate API available while the router migrates", () => {
|
||||
const admission = new SfuResourceAdmission({
|
||||
ingressCapacity: 2,
|
||||
egressCapacity: 3,
|
||||
});
|
||||
const first = publication("1", "publication_first");
|
||||
const second = publication("1", "publication_second");
|
||||
expect(admission.reserve(first, 1)).toBe(true);
|
||||
expect(admission.reserve(first, 1)).toBe(true);
|
||||
expect(admission.reserve(first, 2)).toBe(false);
|
||||
expect(admission.commit(first)).toEqual([]);
|
||||
expect(admission.reserve(second, 2)).toBe(true);
|
||||
expect(admission.commit(second)).toEqual([first]);
|
||||
expect(admission.usage()).toEqual({ ingress: 2, egress: 3 });
|
||||
expect(admission.completeDrain(first)).toBe(true);
|
||||
expect(admission.usage()).toEqual({ ingress: 1, egress: 2 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user