refactor(browser): simplify codec ownership
This commit is contained in:
1 parent
05265c460d
commit
1b7655a34d
35 files changed
+500
-7862
No files matched your search
@@ -154,11 +154,11 @@ export class SignalingClient {
|
||||
return this.setDisplayName(displayName);
|
||||
}
|
||||
|
||||
pauseSharing(): boolean {
|
||||
setSharingPaused(paused: boolean): boolean {
|
||||
if (this.identity.role !== "host") {
|
||||
return false;
|
||||
}
|
||||
this.identity.sharingPaused = true;
|
||||
this.identity.sharingPaused = paused;
|
||||
if (!this.identity.shareGeneration) {
|
||||
return false;
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export class SignalingClient {
|
||||
return this.send({
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration: this.identity.shareGeneration,
|
||||
paused: true,
|
||||
paused,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
@@ -179,47 +179,6 @@ export class SignalingClient {
|
||||
}
|
||||
}
|
||||
|
||||
requestSharingResume(): boolean {
|
||||
if (this.identity.role !== "host" || !this.identity.shareGeneration) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return this.send({
|
||||
type: "request-sharing-resume",
|
||||
shareGeneration: this.identity.shareGeneration,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
confirmSharingResumed(
|
||||
authorization: Extract<
|
||||
ServerMessage,
|
||||
{ type: "sharing-resume-authorized" }
|
||||
>,
|
||||
): boolean {
|
||||
if (
|
||||
this.identity.role !== "host" ||
|
||||
!this.identity.shareGeneration ||
|
||||
authorization.shareGeneration !== this.identity.shareGeneration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let sent = false;
|
||||
try {
|
||||
sent = this.send({
|
||||
type: "sharing-source-enabled",
|
||||
shareGeneration: authorization.shareGeneration,
|
||||
codecGeneration: authorization.codecGeneration,
|
||||
resumeAttempt: authorization.resumeAttempt,
|
||||
});
|
||||
} catch {
|
||||
sent = false;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
sendThenStop(message: ClientMessage): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import {
|
||||
codecProofEvidenceSchema,
|
||||
type ClientMessage,
|
||||
type CodecProofBinding,
|
||||
type ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import type { ConnectionMetrics } from "../types";
|
||||
|
||||
type ProofRequest = Extract<
|
||||
ServerMessage,
|
||||
{ type: "video-codec-proof-request" }
|
||||
>;
|
||||
|
||||
export interface CodecProofRouteSample {
|
||||
routeRevision: number;
|
||||
binding: CodecProofBinding;
|
||||
metrics: ConnectionMetrics | null;
|
||||
}
|
||||
|
||||
interface DecodedBaseline {
|
||||
sampleTimestampMs: number;
|
||||
rtpStatsId: string;
|
||||
rtpSsrc: number | null;
|
||||
rtpMid: string | null;
|
||||
rtpRid: string | null;
|
||||
trackIdentifier: string | null;
|
||||
}
|
||||
|
||||
interface PendingProof {
|
||||
request: ProofRequest;
|
||||
baseline: DecodedBaseline | null;
|
||||
}
|
||||
|
||||
export class CodecProofReporter {
|
||||
private pending: PendingProof | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly send: (message: ClientMessage) => boolean,
|
||||
) {}
|
||||
|
||||
request(request: ProofRequest): boolean {
|
||||
this.pending = {
|
||||
request,
|
||||
baseline: null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
offer(sample: CodecProofRouteSample): void {
|
||||
const pending = this.pending;
|
||||
if (!pending) return;
|
||||
if (!sameRequestRoute(pending.request, sample)) {
|
||||
return;
|
||||
}
|
||||
const current = decodedBaseline(sample.metrics);
|
||||
if (!current) return;
|
||||
if (!pending.baseline) {
|
||||
pending.baseline = current;
|
||||
return;
|
||||
}
|
||||
if (current.sampleTimestampMs <= pending.baseline.sampleTimestampMs) {
|
||||
return;
|
||||
}
|
||||
if (!sameRtpIdentity(pending.baseline, current)) {
|
||||
pending.baseline = current;
|
||||
return;
|
||||
}
|
||||
const framesDecodedDelta = sample.metrics?.intervalFramesDecoded;
|
||||
if (
|
||||
framesDecodedDelta === null ||
|
||||
framesDecodedDelta === undefined ||
|
||||
!Number.isSafeInteger(framesDecodedDelta) ||
|
||||
framesDecodedDelta <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const evidence = codecProofEvidenceSchema.safeParse({
|
||||
baselineSampleTimestampMs: pending.baseline.sampleTimestampMs,
|
||||
sampleTimestampMs: current.sampleTimestampMs,
|
||||
rtpStatsId: current.rtpStatsId,
|
||||
rtpSsrc: current.rtpSsrc,
|
||||
rtpMid: current.rtpMid,
|
||||
rtpRid: current.rtpRid,
|
||||
trackIdentifier: current.trackIdentifier,
|
||||
framesDecodedDelta,
|
||||
actualCodec: actualVideoCodec(sample.metrics?.codec ?? null),
|
||||
});
|
||||
if (!evidence.success) return;
|
||||
if (
|
||||
this.send({
|
||||
type: "video-codec-proof",
|
||||
shareGeneration: pending.request.shareGeneration,
|
||||
generation: pending.request.generation,
|
||||
resumeAttempt: pending.request.resumeAttempt,
|
||||
routeRevision: pending.request.routeRevision,
|
||||
binding: pending.request.binding,
|
||||
evidence: evidence.data,
|
||||
})
|
||||
) {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sameRequestRoute(
|
||||
request: ProofRequest,
|
||||
sample: CodecProofRouteSample,
|
||||
): boolean {
|
||||
return (
|
||||
request.routeRevision === sample.routeRevision &&
|
||||
sameProofBinding(request.binding, sample.binding)
|
||||
);
|
||||
}
|
||||
|
||||
function sameProofBinding(
|
||||
left: CodecProofBinding,
|
||||
right: CodecProofBinding,
|
||||
): boolean {
|
||||
return (
|
||||
left.kind === right.kind &&
|
||||
left.connectionId === right.connectionId &&
|
||||
(left.kind === "peer" ||
|
||||
(right.kind === "sfu" &&
|
||||
left.publicationGeneration === right.publicationGeneration))
|
||||
);
|
||||
}
|
||||
|
||||
function decodedBaseline(
|
||||
metrics: ConnectionMetrics | null,
|
||||
): DecodedBaseline | null {
|
||||
if (
|
||||
!metrics ||
|
||||
!Number.isFinite(metrics.sampleTimestampMs) ||
|
||||
metrics.sampleTimestampMs === null ||
|
||||
metrics.sampleTimestampMs < 0 ||
|
||||
!validIdentityString(metrics.rtpStatsId) ||
|
||||
!validOptionalIdentityString(metrics.rtpMid) ||
|
||||
!validOptionalIdentityString(metrics.rtpRid) ||
|
||||
!validOptionalIdentityString(metrics.trackIdentifier) ||
|
||||
(metrics.rtpSsrc !== null &&
|
||||
(!Number.isSafeInteger(metrics.rtpSsrc) ||
|
||||
metrics.rtpSsrc < 0 ||
|
||||
metrics.rtpSsrc > 0xffff_ffff))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sampleTimestampMs: metrics.sampleTimestampMs,
|
||||
rtpStatsId: metrics.rtpStatsId,
|
||||
rtpSsrc: metrics.rtpSsrc,
|
||||
rtpMid: metrics.rtpMid,
|
||||
rtpRid: metrics.rtpRid,
|
||||
trackIdentifier: metrics.trackIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
function validIdentityString(value: string | null): value is string {
|
||||
return value !== null && value.length >= 1 && value.length <= 512;
|
||||
}
|
||||
|
||||
function validOptionalIdentityString(value: string | null): boolean {
|
||||
return value === null || validIdentityString(value);
|
||||
}
|
||||
|
||||
function sameRtpIdentity(
|
||||
left: DecodedBaseline,
|
||||
right: DecodedBaseline,
|
||||
): boolean {
|
||||
return (
|
||||
left.rtpStatsId === right.rtpStatsId &&
|
||||
left.rtpSsrc === right.rtpSsrc &&
|
||||
left.rtpMid === right.rtpMid &&
|
||||
left.rtpRid === right.rtpRid &&
|
||||
left.trackIdentifier === right.trackIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
function actualVideoCodec(codec: string | null): "h264" | "vp8" | "other" {
|
||||
const normalized = codec?.toLowerCase() ?? "";
|
||||
if (normalized.includes("h264")) return "h264";
|
||||
if (normalized.includes("vp8")) return "vp8";
|
||||
return "other";
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import type {
|
||||
ClientMessage,
|
||||
CodecPreparationBinding,
|
||||
CodecTransitionGeneration,
|
||||
MediaRoutePhase,
|
||||
ParticipantRouteAssignment,
|
||||
PreparedRouteCandidate,
|
||||
ServerMessage,
|
||||
VideoCodecPreference,
|
||||
} from "../../shared/protocol";
|
||||
import {
|
||||
SfuPublisher,
|
||||
@@ -47,8 +43,6 @@ interface HostPublisherSlot {
|
||||
connected: boolean;
|
||||
active: boolean;
|
||||
failed: boolean;
|
||||
codecTransition: PreparedRouteCandidate["codecTransition"];
|
||||
codecPreparationReported: boolean;
|
||||
}
|
||||
|
||||
export interface HostSfuPublisherSnapshot {
|
||||
@@ -57,22 +51,12 @@ export interface HostSfuPublisherSnapshot {
|
||||
audioSenderParameters: AudioSenderParameterReadback | null;
|
||||
}
|
||||
|
||||
export interface HostSfuCodecPreparationResult {
|
||||
generation: CodecTransitionGeneration;
|
||||
videoCodec: VideoCodecPreference;
|
||||
binding: Extract<CodecPreparationBinding, { kind: "sfu" }>;
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
interface HostSfuRouteEvents {
|
||||
getStream: () => MediaStream | null;
|
||||
getProfile: () => QualityProfile;
|
||||
reconcileChildren: (childPeerIds: string[]) => void;
|
||||
send: (message: ClientMessage) => boolean;
|
||||
onPublisherUpdate?: (snapshot: HostSfuPublisherSnapshot | null) => void;
|
||||
onCodecPublisherPrepared?: (
|
||||
result: HostSfuCodecPreparationResult,
|
||||
) => void;
|
||||
createPublisher?: (
|
||||
onDisconnected: () => void,
|
||||
onStats: (metrics: ConnectionMetrics | null) => void,
|
||||
@@ -112,11 +96,7 @@ export class HostSfuRoute {
|
||||
if (pausedActiveReconciliation) {
|
||||
this.events.reconcileChildren(update.assignment.childPeerIds);
|
||||
}
|
||||
if (
|
||||
this.paused &&
|
||||
update.phase === "prepare" &&
|
||||
!isPausedCodecSfuCandidate(update)
|
||||
) {
|
||||
if (this.paused && update.phase === "prepare") {
|
||||
this.clearPending();
|
||||
return result;
|
||||
}
|
||||
@@ -219,7 +199,7 @@ export class HostSfuRoute {
|
||||
if (
|
||||
this.closed ||
|
||||
!this.route.acceptsConfig(message.revision) ||
|
||||
(this.paused && !this.hasPausedCodecSfuCandidate())
|
||||
this.paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -228,8 +208,6 @@ export class HostSfuRoute {
|
||||
const phase = this.route.getPhase();
|
||||
const candidate = this.route.getPreparedCandidate();
|
||||
const publicationGeneration = assignment?.sfuPublicationGeneration;
|
||||
const codecTransition =
|
||||
phase === "prepare" ? candidate?.codecTransition ?? null : null;
|
||||
if (
|
||||
!token ||
|
||||
!phase ||
|
||||
@@ -239,18 +217,6 @@ export class HostSfuRoute {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
codecTransition !== null &&
|
||||
this.active?.publicationGeneration === publicationGeneration
|
||||
) {
|
||||
this.events.onCodecPublisherPrepared?.({
|
||||
...codecTransition,
|
||||
binding: { kind: "sfu", publicationGeneration },
|
||||
accepted: false,
|
||||
});
|
||||
this.routeFailed(message.revision, "prepare", null);
|
||||
return;
|
||||
}
|
||||
if (this.active?.publicationGeneration === publicationGeneration) {
|
||||
this.active.revision = message.revision;
|
||||
if (phase === "active" && this.active.active) {
|
||||
@@ -284,8 +250,6 @@ export class HostSfuRoute {
|
||||
connected: false,
|
||||
active: false,
|
||||
failed: false,
|
||||
codecTransition: codecTransition ? { ...codecTransition } : null,
|
||||
codecPreparationReported: false,
|
||||
};
|
||||
this.pending = slot;
|
||||
try {
|
||||
@@ -330,7 +294,7 @@ export class HostSfuRoute {
|
||||
|
||||
setPaused(paused: boolean): void {
|
||||
this.paused = paused;
|
||||
if (paused && !this.hasPausedCodecSfuCandidate()) {
|
||||
if (paused) {
|
||||
this.clearPending();
|
||||
}
|
||||
}
|
||||
@@ -342,7 +306,7 @@ export class HostSfuRoute {
|
||||
: Promise.all(
|
||||
slots.map((slot) =>
|
||||
slot.publisher
|
||||
.updateProfile(this.profileForSlot(slot, profile))
|
||||
.updateProfile(profile)
|
||||
.catch(() => false),
|
||||
),
|
||||
).then((results) => results.every(Boolean));
|
||||
@@ -516,7 +480,7 @@ export class HostSfuRoute {
|
||||
|
||||
private async preparePublisher(slot: HostPublisherSlot): Promise<boolean> {
|
||||
const stream = this.events.getStream();
|
||||
const profile = this.profileForSlot(slot, this.events.getProfile());
|
||||
const profile = this.events.getProfile();
|
||||
if (!stream) {
|
||||
this.handleFailure(slot);
|
||||
return false;
|
||||
@@ -542,7 +506,7 @@ export class HostSfuRoute {
|
||||
await disconnectPublisher(slot.publisher);
|
||||
return false;
|
||||
}
|
||||
const latestProfile = this.profileForSlot(slot, this.events.getProfile());
|
||||
const latestProfile = this.events.getProfile();
|
||||
if (
|
||||
!qualitySettingsEqual(latestProfile, profile) &&
|
||||
!(await slot.publisher.updateProfile(latestProfile))
|
||||
@@ -551,11 +515,7 @@ export class HostSfuRoute {
|
||||
await disconnectPublisher(slot.publisher);
|
||||
return false;
|
||||
}
|
||||
const prepared = this.pending === slot && this.ownsPublisherSlot(slot);
|
||||
if (prepared) {
|
||||
this.reportCodecPreparation(slot, true);
|
||||
}
|
||||
return prepared;
|
||||
return this.pending === slot && this.ownsPublisherSlot(slot);
|
||||
} catch {
|
||||
this.handleFailure(slot);
|
||||
await disconnectPublisher(slot.publisher);
|
||||
@@ -571,42 +531,6 @@ export class HostSfuRoute {
|
||||
);
|
||||
}
|
||||
|
||||
private hasPausedCodecSfuCandidate(): boolean {
|
||||
const candidate = this.route.getPreparedCandidate();
|
||||
return (
|
||||
this.route.getPhase() === "prepare" &&
|
||||
candidate?.transport === "sfu" &&
|
||||
candidate.codecTransition !== null
|
||||
);
|
||||
}
|
||||
|
||||
private profileForSlot(
|
||||
slot: HostPublisherSlot,
|
||||
profile: QualityProfile,
|
||||
): QualityProfile {
|
||||
return slot.codecTransition
|
||||
? { ...profile, videoCodec: slot.codecTransition.videoCodec }
|
||||
: profile;
|
||||
}
|
||||
|
||||
private reportCodecPreparation(
|
||||
slot: HostPublisherSlot,
|
||||
accepted: boolean,
|
||||
): void {
|
||||
if (!slot.codecTransition || slot.codecPreparationReported) {
|
||||
return;
|
||||
}
|
||||
slot.codecPreparationReported = true;
|
||||
this.events.onCodecPublisherPrepared?.({
|
||||
...slot.codecTransition,
|
||||
binding: {
|
||||
kind: "sfu",
|
||||
publicationGeneration: slot.publicationGeneration,
|
||||
},
|
||||
accepted,
|
||||
});
|
||||
}
|
||||
|
||||
private currentActiveToken(
|
||||
slot: HostPublisherSlot,
|
||||
): RouteOperationToken | null {
|
||||
@@ -699,9 +623,6 @@ export class HostSfuRoute {
|
||||
this.active = null;
|
||||
}
|
||||
|
||||
if (matchesPlannedRoute) {
|
||||
this.reportCodecPreparation(slot, false);
|
||||
}
|
||||
if (revision === null || phase === null) {
|
||||
return;
|
||||
}
|
||||
@@ -769,14 +690,6 @@ export class HostSfuRoute {
|
||||
}
|
||||
}
|
||||
|
||||
function isPausedCodecSfuCandidate(update: RouteUpdateInput): boolean {
|
||||
return (
|
||||
update.phase === "prepare" &&
|
||||
update.candidate.transport === "sfu" &&
|
||||
update.candidate.codecTransition !== null
|
||||
);
|
||||
}
|
||||
|
||||
async function disconnectPublisher(
|
||||
publisher: HostPublisherTransport,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type {
|
||||
CodecTransitionGeneration,
|
||||
ResumeAttempt,
|
||||
ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { setMediaPaused } from "./quality";
|
||||
|
||||
type PauseSharingSourceMessage = Extract<
|
||||
ServerMessage,
|
||||
{ type: "pause-sharing-source" }
|
||||
>;
|
||||
|
||||
interface AuthoritativeHostPauseInput {
|
||||
message: PauseSharingSourceMessage;
|
||||
currentShareGeneration: string | null;
|
||||
activeResumeAttempt: ResumeAttempt | null;
|
||||
activeCodecGeneration: CodecTransitionGeneration | null;
|
||||
stream: MediaStream | null;
|
||||
pauseSfuRoute: () => void;
|
||||
markHostPaused: () => void;
|
||||
}
|
||||
|
||||
export interface AuthoritativeHostPauseResult {
|
||||
localCodecContextMatches: boolean;
|
||||
}
|
||||
|
||||
export function applyAuthoritativeHostPause({
|
||||
message,
|
||||
currentShareGeneration,
|
||||
activeResumeAttempt,
|
||||
activeCodecGeneration,
|
||||
stream,
|
||||
pauseSfuRoute,
|
||||
markHostPaused,
|
||||
}: AuthoritativeHostPauseInput): AuthoritativeHostPauseResult | null {
|
||||
if (message.shareGeneration !== currentShareGeneration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
setMediaPaused(stream, true);
|
||||
}
|
||||
pauseSfuRoute();
|
||||
markHostPaused();
|
||||
|
||||
return {
|
||||
localCodecContextMatches:
|
||||
(message.resumeAttempt === null ||
|
||||
message.resumeAttempt === activeResumeAttempt) &&
|
||||
(message.codecGeneration === null ||
|
||||
message.codecGeneration === activeCodecGeneration),
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const QUALITY_PROFILES = {
|
||||
maxFramerate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
degradationPreference: "balanced",
|
||||
videoCodec: "automatic",
|
||||
videoCodec: "vp8",
|
||||
screenAudioQuality: "music",
|
||||
},
|
||||
"1080p30": DEFAULT_QUALITY_SETTINGS,
|
||||
@@ -35,7 +35,7 @@ export const QUALITY_PROFILES = {
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "balanced",
|
||||
videoCodec: "automatic",
|
||||
videoCodec: "vp8",
|
||||
screenAudioQuality: "music",
|
||||
},
|
||||
} as const satisfies Record<QualityProfileId, QualitySettings>;
|
||||
@@ -145,7 +145,7 @@ export function videoQualitySettingsEqual(
|
||||
left.maxFramerate === right.maxFramerate &&
|
||||
left.maxBitrate === right.maxBitrate &&
|
||||
left.degradationPreference === right.degradationPreference &&
|
||||
(left.videoCodec ?? "automatic") === (right.videoCodec ?? "automatic")
|
||||
(left.videoCodec ?? "vp8") === (right.videoCodec ?? "vp8")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,34 +151,14 @@ function sameCandidate(
|
||||
return (
|
||||
left?.childPeerId === right.childPeerId &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.transport === right.transport &&
|
||||
sameCodecTransition(left.codecTransition, right.codecTransition)
|
||||
);
|
||||
}
|
||||
|
||||
function sameCodecTransition(
|
||||
left: PreparedRouteCandidate["codecTransition"] | undefined,
|
||||
right: PreparedRouteCandidate["codecTransition"],
|
||||
): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
(left !== null &&
|
||||
left !== undefined &&
|
||||
right !== null &&
|
||||
left.generation === right.generation &&
|
||||
left.videoCodec === right.videoCodec)
|
||||
left.transport === right.transport
|
||||
);
|
||||
}
|
||||
|
||||
function cloneCandidate(
|
||||
candidate: PreparedRouteCandidate,
|
||||
): PreparedRouteCandidate {
|
||||
return {
|
||||
...candidate,
|
||||
codecTransition: candidate.codecTransition
|
||||
? { ...candidate.codecTransition }
|
||||
: null,
|
||||
};
|
||||
return { ...candidate };
|
||||
}
|
||||
|
||||
export function reportActivePeerRouteFailure(
|
||||
|
||||
@@ -119,11 +119,7 @@ export class ViewerSfuRoute {
|
||||
if (pausedActiveReconciliation) {
|
||||
this.activateChildren(update.assignment.childPeerIds, update.revision);
|
||||
}
|
||||
if (
|
||||
this.paused &&
|
||||
update.phase === "prepare" &&
|
||||
!isPausedCodecSfuCandidate(update)
|
||||
) {
|
||||
if (this.paused && update.phase === "prepare") {
|
||||
this.discardPending();
|
||||
return result;
|
||||
}
|
||||
@@ -203,21 +199,9 @@ export class ViewerSfuRoute {
|
||||
}
|
||||
|
||||
setPaused(paused: boolean): void {
|
||||
const wasPaused = this.paused;
|
||||
this.paused = paused;
|
||||
if (paused && !this.hasPausedCodecSfuCandidate()) {
|
||||
if (paused) {
|
||||
this.discardPending();
|
||||
} else if (paused && this.pending) {
|
||||
this.pending.decodedFrame = false;
|
||||
this.pending.readySent = false;
|
||||
this.pending.subscriber.stopDecodedFrameProof();
|
||||
} else if (
|
||||
wasPaused &&
|
||||
!paused &&
|
||||
this.pending &&
|
||||
this.hasPausedCodecSfuCandidate()
|
||||
) {
|
||||
this.pending.subscriber.armDecodedFrameProof(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +278,7 @@ export class ViewerSfuRoute {
|
||||
if (
|
||||
this.closed ||
|
||||
!this.route.acceptsConfig(message.revision) ||
|
||||
(this.paused && !this.hasPausedCodecSfuCandidate())
|
||||
this.paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -461,16 +445,6 @@ export class ViewerSfuRoute {
|
||||
);
|
||||
}
|
||||
|
||||
private hasPausedCodecSfuCandidate(): boolean {
|
||||
const candidate = this.route.getPreparedCandidate();
|
||||
return (
|
||||
this.route.getPhase() === "prepare" &&
|
||||
candidate?.childPeerId === this.viewerPeerId &&
|
||||
candidate.transport === "sfu" &&
|
||||
candidate.codecTransition !== null
|
||||
);
|
||||
}
|
||||
|
||||
private queueActiveRoute(
|
||||
token: RouteOperationToken,
|
||||
acknowledge: boolean,
|
||||
@@ -804,14 +778,6 @@ export class ViewerSfuRoute {
|
||||
|
||||
}
|
||||
|
||||
function isPausedCodecSfuCandidate(update: RouteUpdateInput): boolean {
|
||||
return (
|
||||
update.phase === "prepare" &&
|
||||
update.candidate.transport === "sfu" &&
|
||||
update.candidate.codecTransition !== null
|
||||
);
|
||||
}
|
||||
|
||||
async function disconnectSubscriber(
|
||||
subscriber: ViewerSubscriberTransport,
|
||||
): Promise<void> {
|
||||
|
||||
+56
-421
@@ -28,8 +28,6 @@ import {
|
||||
type PreparedRouteCandidate,
|
||||
type ServerMessage,
|
||||
type CodeEntryPolicy,
|
||||
type CodecTransitionGeneration,
|
||||
type ResumeAttempt,
|
||||
} from "../../shared/protocol";
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { ConnectionSelfCheck } from "../components/ConnectionSelfCheck";
|
||||
@@ -98,7 +96,6 @@ import {
|
||||
HostSfuRoute,
|
||||
type HostSfuPublisherSnapshot,
|
||||
} from "../media/host-sfu-route";
|
||||
import { applyAuthoritativeHostPause } from "../media/host-source-authority";
|
||||
import {
|
||||
HostProvisionalChild,
|
||||
} from "../media/host-provisional-child";
|
||||
@@ -128,24 +125,10 @@ import {
|
||||
shouldPauseLocalPreview,
|
||||
sourceSwitchNotice,
|
||||
type HostAction,
|
||||
VIDEO_CODEC_TRANSITION_FAILED_NOTICE,
|
||||
videoCodecLockNotice,
|
||||
} from "./host-page-notices";
|
||||
|
||||
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
|
||||
type CodecTransitionPhase =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "proving"
|
||||
| "rollback-preparing"
|
||||
| "rollback-prepared"
|
||||
| "failed";
|
||||
|
||||
interface CodecTransitionState {
|
||||
generation: CodecTransitionGeneration | null;
|
||||
requested: VideoCodecPreference;
|
||||
rollback: boolean;
|
||||
}
|
||||
|
||||
type ViewerQualityEvidence = Extract<
|
||||
ServerMessage,
|
||||
@@ -291,9 +274,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const [switchingSource, setSwitchingSource] = useState(false);
|
||||
const [changingQuality, setChangingQuality] = useState(false);
|
||||
const [sharingPaused, setSharingPaused] = useState(false);
|
||||
const [resumePending, setResumePending] = useState(false);
|
||||
const [codecTransitionPhase, setCodecTransitionPhase] =
|
||||
useState<CodecTransitionPhase | null>(null);
|
||||
const [localPreviewPaused, setLocalPreviewPaused] = useState(false);
|
||||
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
||||
const [showTopology, setShowTopology] = useState(false);
|
||||
@@ -329,10 +309,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const qualityChangeRef = useRef<object | null>(null);
|
||||
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
|
||||
const sharingPausedRef = useRef(false);
|
||||
const resumePendingRef = useRef(false);
|
||||
const activeResumeAttemptRef = useRef<ResumeAttempt | null>(null);
|
||||
const codecTransitionRef = useRef<CodecTransitionState | null>(null);
|
||||
const codecTransitionPhaseRef = useRef<CodecTransitionPhase | null>(null);
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(null);
|
||||
@@ -510,31 +486,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setSfuPublisherSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
onCodecPublisherPrepared: (result) => {
|
||||
const shareGeneration = shareGenerationRef.current;
|
||||
if (
|
||||
!shareGeneration ||
|
||||
!isCurrentGeneration(generation) ||
|
||||
hostSfuRouteRef.current !== route
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rollback =
|
||||
codecTransitionRef.current?.rollback === true ||
|
||||
codecTransitionPhaseRef.current === "rollback-preparing";
|
||||
codecTransitionRef.current = {
|
||||
generation: result.generation,
|
||||
requested: result.videoCodec,
|
||||
rollback,
|
||||
};
|
||||
signalRef.current?.send({
|
||||
type: "video-codec-prepared",
|
||||
shareGeneration,
|
||||
generation: result.generation,
|
||||
binding: result.binding,
|
||||
accepted: result.accepted,
|
||||
});
|
||||
},
|
||||
});
|
||||
hostSfuRouteRef.current = route;
|
||||
return route;
|
||||
@@ -625,12 +576,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setChangingQuality(false);
|
||||
sharingPausedRef.current = false;
|
||||
setSharingPaused(false);
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
activeResumeAttemptRef.current = null;
|
||||
codecTransitionRef.current = null;
|
||||
codecTransitionPhaseRef.current = null;
|
||||
setCodecTransitionPhase(null);
|
||||
}
|
||||
|
||||
function forgetRoom(): void {
|
||||
@@ -794,45 +739,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
|
||||
function changeVideoCodec(videoCodec: VideoCodecPreference): void {
|
||||
if (phase === "starting") {
|
||||
return;
|
||||
}
|
||||
if (phase === "live") {
|
||||
const shareGeneration = shareGenerationRef.current;
|
||||
if (
|
||||
!sharingPausedRef.current ||
|
||||
!peerAssistedRef.current ||
|
||||
!shareGeneration ||
|
||||
changingQuality ||
|
||||
switchingSource ||
|
||||
codecTransitionRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(qualitySettingsRef.current.videoCodec ?? "automatic") === videoCodec
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
signalRef.current?.send({
|
||||
type: "request-video-codec-transition",
|
||||
shareGeneration,
|
||||
videoCodec,
|
||||
}) !== true
|
||||
) {
|
||||
setNotice("信令正在恢复,暂时无法切换视频编码");
|
||||
return;
|
||||
}
|
||||
codecTransitionRef.current = {
|
||||
generation: null,
|
||||
requested: videoCodec,
|
||||
rollback: false,
|
||||
};
|
||||
codecTransitionPhaseRef.current = "preparing";
|
||||
setCodecTransitionPhase("preparing");
|
||||
setAdvancedQuality((current) => ({ ...current, videoCodec }));
|
||||
setNotice("正在准备视频编码");
|
||||
if (phase === "starting" || phase === "live") {
|
||||
return;
|
||||
}
|
||||
const next = { ...qualitySettingsRef.current, videoCodec };
|
||||
@@ -856,24 +763,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
preserveAdvancedDraft = false,
|
||||
): Promise<void> {
|
||||
const previousProfile = qualitySettingsRef.current;
|
||||
const codecTransition = codecTransitionRef.current;
|
||||
if (
|
||||
phase === "live" &&
|
||||
(nextProfile.videoCodec ?? "automatic") !==
|
||||
(previousProfile.videoCodec ?? "automatic")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
phase === "live" &&
|
||||
codecTransition &&
|
||||
!videoQualitySettingsEqual(previousProfile, nextProfile)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
phase === "live" &&
|
||||
codecTransitionPhaseRef.current === "failed"
|
||||
(nextProfile.videoCodec ?? "vp8") !==
|
||||
(previousProfile.videoCodec ?? "vp8")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -905,10 +798,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const audioChanged =
|
||||
resolveScreenAudioQuality(previousProfile.screenAudioQuality) !==
|
||||
resolveScreenAudioQuality(nextProfile.screenAudioQuality);
|
||||
const endpointProfile = codecTransition
|
||||
? { ...nextProfile, videoCodec: codecTransition.requested }
|
||||
: nextProfile;
|
||||
|
||||
try {
|
||||
if (videoChanged) {
|
||||
await applyCaptureProfile(activeStream, nextProfile);
|
||||
@@ -936,14 +825,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
Promise.all(
|
||||
[
|
||||
...[...peersRef.current.values()].map((peer) =>
|
||||
peer.updateProfile(endpointProfile),
|
||||
peer.updateProfile(nextProfile),
|
||||
),
|
||||
...(hostProvisionalChildRef.current
|
||||
? [hostProvisionalChildRef.current.updateProfile(endpointProfile)]
|
||||
? [hostProvisionalChildRef.current.updateProfile(nextProfile)]
|
||||
: []),
|
||||
],
|
||||
),
|
||||
activeSfuRoute?.updateProfile(endpointProfile) ??
|
||||
activeSfuRoute?.updateProfile(nextProfile) ??
|
||||
Promise.resolve(true),
|
||||
]);
|
||||
if (
|
||||
@@ -994,16 +883,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (sharingPausedRef.current) {
|
||||
if (resumePendingRef.current) {
|
||||
if (!setMediaPaused(activeStream, false)) {
|
||||
setNotice("当前分享没有可恢复的媒体轨道");
|
||||
return;
|
||||
}
|
||||
if (signalRef.current?.requestSharingResume() !== true) {
|
||||
hostSfuRouteRef.current?.setPaused(false);
|
||||
if (signalRef.current?.setSharingPaused(false) !== true) {
|
||||
setMediaPaused(activeStream, true);
|
||||
hostSfuRouteRef.current?.setPaused(true);
|
||||
signalRef.current?.confirmSharingPaused();
|
||||
setNotice("信令正在恢复,分享仍保持暂停");
|
||||
return;
|
||||
}
|
||||
resumePendingRef.current = true;
|
||||
setResumePending(true);
|
||||
setNotice("正在恢复分享");
|
||||
sharingPausedRef.current = false;
|
||||
setSharingPaused(false);
|
||||
setNotice("音视频分享已恢复");
|
||||
return;
|
||||
}
|
||||
if (!setMediaPaused(activeStream, true)) {
|
||||
@@ -1014,17 +908,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setSharingPaused(true);
|
||||
hostSfuRouteRef.current?.setPaused(true);
|
||||
discardPreparedHostChild();
|
||||
signalRef.current?.pauseSharing();
|
||||
activeResumeAttemptRef.current = null;
|
||||
const codecState = codecTransitionRef.current;
|
||||
if (codecState && codecTransitionPhaseRef.current === "proving") {
|
||||
const nextPhase = codecState.rollback
|
||||
? "rollback-prepared"
|
||||
: "prepared";
|
||||
codecTransitionPhaseRef.current = nextPhase;
|
||||
setCodecTransitionPhase(nextPhase);
|
||||
}
|
||||
setNotice("音视频分享已暂停");
|
||||
setNotice(
|
||||
signalRef.current?.setSharingPaused(true) === true
|
||||
? "音视频分享已暂停"
|
||||
: "信令正在恢复,分享保持暂停",
|
||||
);
|
||||
}
|
||||
|
||||
async function enterPreviewFullscreen(): Promise<void> {
|
||||
@@ -1280,55 +1168,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareVideoCodec(
|
||||
message: Extract<ServerMessage, { type: "video-codec-prepare" }>,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const shareGeneration = shareGenerationRef.current;
|
||||
if (
|
||||
!shareGeneration ||
|
||||
message.shareGeneration !== shareGeneration ||
|
||||
!isCurrentGeneration(generation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollback =
|
||||
codecTransitionRef.current?.rollback === true ||
|
||||
codecTransitionPhaseRef.current === "rollback-preparing";
|
||||
codecTransitionRef.current = {
|
||||
generation: message.generation,
|
||||
requested: message.videoCodec,
|
||||
rollback,
|
||||
};
|
||||
|
||||
let accepted = false;
|
||||
if (message.binding.kind === "peer") {
|
||||
const peer = peersRef.current.get(message.binding.childPeerId);
|
||||
if (peer?.connectionId === message.binding.connectionId) {
|
||||
peer.cancelVideoCodecPreparation();
|
||||
accepted = await peer.prepareVideoCodec(
|
||||
message.generation,
|
||||
message.videoCodec,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isCurrentGeneration(generation) ||
|
||||
shareGenerationRef.current !== shareGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
signalRef.current?.send({
|
||||
type: "video-codec-prepared",
|
||||
shareGeneration,
|
||||
generation: message.generation,
|
||||
binding: message.binding,
|
||||
accepted,
|
||||
});
|
||||
}
|
||||
|
||||
function handleSignalMessage(
|
||||
message: ServerMessage,
|
||||
generation: number,
|
||||
@@ -1339,15 +1178,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
peersRef.current.forEach((peer) =>
|
||||
peer.cancelVideoCodecPreparation(),
|
||||
);
|
||||
codecTransitionRef.current = null;
|
||||
codecTransitionPhaseRef.current = null;
|
||||
setCodecTransitionPhase(null);
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
activeResumeAttemptRef.current = null;
|
||||
discardPreparedHostChild();
|
||||
hostPeerIdRef.current = message.peerId;
|
||||
endpointMediaCopyCapacityRef.current = message.endpointMediaCopyCapacity;
|
||||
@@ -1374,32 +1204,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
...message.routeAssignment.childPeerIds,
|
||||
];
|
||||
peerAssistedRef.current = true;
|
||||
const activeCodecTransition = codecTransitionRef.current;
|
||||
if (reauthenticated) {
|
||||
commitQuality(
|
||||
message.qualitySettings,
|
||||
activeCodecTransition !== null,
|
||||
);
|
||||
if (activeCodecTransition === null) {
|
||||
const endpointUpdates = [
|
||||
...[...peersRef.current.values()].map((peer) =>
|
||||
peer.updateProfile(message.qualitySettings),
|
||||
),
|
||||
...(hostProvisionalChildRef.current
|
||||
? [
|
||||
hostProvisionalChildRef.current.updateProfile(
|
||||
message.qualitySettings,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
void Promise.allSettled(endpointUpdates);
|
||||
}
|
||||
} else {
|
||||
signalRef.current?.send({
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: qualitySettingsRef.current,
|
||||
});
|
||||
commitQuality(message.qualitySettings);
|
||||
const endpointUpdates = [
|
||||
...[...peersRef.current.values()].map((peer) =>
|
||||
peer.updateProfile(message.qualitySettings),
|
||||
),
|
||||
...(hostProvisionalChildRef.current
|
||||
? [
|
||||
hostProvisionalChildRef.current.updateProfile(
|
||||
message.qualitySettings,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
void Promise.allSettled(endpointUpdates);
|
||||
}
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
@@ -1409,11 +1228,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
assignment: message.routeAssignment,
|
||||
})
|
||||
.then(async () => {
|
||||
if (
|
||||
reauthenticated &&
|
||||
codecTransitionRef.current === null &&
|
||||
hostSfuRouteRef.current === route
|
||||
) {
|
||||
if (reauthenticated && hostSfuRouteRef.current === route) {
|
||||
await route.updateProfile(message.qualitySettings);
|
||||
}
|
||||
showHostSfuQualityWarning(route, generation);
|
||||
@@ -1503,166 +1318,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
downloadDiagnosticReport("host", connections, message.snapshot);
|
||||
return;
|
||||
}
|
||||
if (message.type === "video-codec-prepare") {
|
||||
void prepareVideoCodec(message, generation);
|
||||
return;
|
||||
}
|
||||
if (message.type === "video-codec-result") {
|
||||
let transition = codecTransitionRef.current;
|
||||
if (
|
||||
!transition &&
|
||||
message.status === "failed" &&
|
||||
message.failure === "stale-binding"
|
||||
) {
|
||||
transition = {
|
||||
generation: message.generation,
|
||||
requested: message.videoCodec,
|
||||
rollback: true,
|
||||
};
|
||||
codecTransitionRef.current = transition;
|
||||
}
|
||||
if (
|
||||
!transition ||
|
||||
(transition.generation !== null &&
|
||||
transition.generation !== message.generation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
transition.generation = message.generation;
|
||||
if (message.status === "prepared") {
|
||||
codecTransitionPhaseRef.current = "prepared";
|
||||
setCodecTransitionPhase("prepared");
|
||||
setNotice("视频编码已准备,恢复分享后完成验证");
|
||||
return;
|
||||
}
|
||||
if (message.status === "rollback-prepared") {
|
||||
transition.rollback = true;
|
||||
transition.requested = message.videoCodec;
|
||||
codecTransitionPhaseRef.current = "rollback-prepared";
|
||||
setCodecTransitionPhase("rollback-prepared");
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
videoCodec: message.videoCodec,
|
||||
}));
|
||||
setNotice("新编码未通过验证;原编码已准备,恢复分享后完成恢复");
|
||||
return;
|
||||
}
|
||||
if (message.status === "committed") {
|
||||
activeResumeAttemptRef.current = null;
|
||||
codecTransitionRef.current = null;
|
||||
codecTransitionPhaseRef.current = null;
|
||||
setCodecTransitionPhase(null);
|
||||
commitQuality(
|
||||
{ ...qualitySettingsRef.current, videoCodec: message.videoCodec },
|
||||
true,
|
||||
);
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
videoCodec: message.videoCodec,
|
||||
}));
|
||||
setNotice(
|
||||
message.failure
|
||||
? "已恢复原视频编码"
|
||||
: `视频编码已切换为 ${VIDEO_CODEC_PREFERENCE_LABELS[message.videoCodec]}`,
|
||||
);
|
||||
} else {
|
||||
transition.requested = message.videoCodec;
|
||||
transition.rollback = true;
|
||||
codecTransitionPhaseRef.current = "failed";
|
||||
setCodecTransitionPhase("failed");
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
activeResumeAttemptRef.current = null;
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
videoCodec: message.videoCodec,
|
||||
}));
|
||||
setNotice(VIDEO_CODEC_TRANSITION_FAILED_NOTICE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "sharing-resume-authorized") {
|
||||
if (message.type === "pause-sharing-source") {
|
||||
if (message.shareGeneration !== shareGenerationRef.current) {
|
||||
return;
|
||||
}
|
||||
const transition = codecTransitionRef.current;
|
||||
if (
|
||||
(message.codecGeneration === null && transition !== null) ||
|
||||
(message.codecGeneration !== null &&
|
||||
(transition?.generation !== message.codecGeneration ||
|
||||
(codecTransitionPhaseRef.current !== "prepared" &&
|
||||
codecTransitionPhaseRef.current !== "rollback-prepared")))
|
||||
) {
|
||||
activeResumeAttemptRef.current = null;
|
||||
signalRef.current?.pauseSharing();
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
setNotice("恢复授权已失效,分享仍保持暂停");
|
||||
return;
|
||||
}
|
||||
activeResumeAttemptRef.current = message.resumeAttempt;
|
||||
const activeStream = streamRef.current;
|
||||
if (!activeStream || !setMediaPaused(activeStream, false)) {
|
||||
activeResumeAttemptRef.current = null;
|
||||
signalRef.current?.pauseSharing();
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
setNotice("当前分享没有可恢复的媒体轨道");
|
||||
return;
|
||||
}
|
||||
hostSfuRouteRef.current?.setPaused(false);
|
||||
if (signalRef.current?.confirmSharingResumed(message) !== true) {
|
||||
if (activeStream) {
|
||||
setMediaPaused(activeStream, true);
|
||||
hostSfuRouteRef.current?.setPaused(true);
|
||||
signalRef.current?.pauseSharing();
|
||||
activeResumeAttemptRef.current = null;
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
setNotice("信令正在恢复,分享仍保持暂停");
|
||||
return;
|
||||
}
|
||||
sharingPausedRef.current = false;
|
||||
setSharingPaused(false);
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
if (message.codecGeneration !== null) {
|
||||
codecTransitionPhaseRef.current = "proving";
|
||||
setCodecTransitionPhase("proving");
|
||||
setNotice("正在验证视频编码");
|
||||
} else {
|
||||
setNotice("音视频分享已恢复");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "pause-sharing-source") {
|
||||
const transition = codecTransitionRef.current;
|
||||
const pause = applyAuthoritativeHostPause({
|
||||
message,
|
||||
currentShareGeneration: shareGenerationRef.current,
|
||||
activeResumeAttempt: activeResumeAttemptRef.current,
|
||||
activeCodecGeneration: transition?.generation ?? null,
|
||||
stream: streamRef.current,
|
||||
pauseSfuRoute: () => hostSfuRouteRef.current?.setPaused(true),
|
||||
markHostPaused: () => {
|
||||
sharingPausedRef.current = true;
|
||||
setSharingPaused(true);
|
||||
signalRef.current?.confirmSharingPaused();
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
activeResumeAttemptRef.current = null;
|
||||
},
|
||||
});
|
||||
if (!pause) {
|
||||
return;
|
||||
}
|
||||
if (transition && pause.localCodecContextMatches) {
|
||||
transition.rollback = true;
|
||||
codecTransitionPhaseRef.current = "rollback-preparing";
|
||||
setCodecTransitionPhase("rollback-preparing");
|
||||
setNotice("新编码未通过验证,正在恢复原编码");
|
||||
} else {
|
||||
setNotice("分享仍保持暂停");
|
||||
}
|
||||
hostSfuRouteRef.current?.setPaused(true);
|
||||
sharingPausedRef.current = true;
|
||||
setSharingPaused(true);
|
||||
signalRef.current?.confirmSharingPaused();
|
||||
setNotice("分享仍保持暂停");
|
||||
return;
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
@@ -1779,20 +1447,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
if (
|
||||
codecTransitionRef.current?.generation === null &&
|
||||
codecTransitionPhaseRef.current === "preparing"
|
||||
) {
|
||||
codecTransitionRef.current = null;
|
||||
codecTransitionPhaseRef.current = null;
|
||||
setCodecTransitionPhase(null);
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
videoCodec: qualitySettingsRef.current.videoCodec ?? "automatic",
|
||||
}));
|
||||
}
|
||||
resumePendingRef.current = false;
|
||||
setResumePending(false);
|
||||
setViewerGrantUpdating(false);
|
||||
setViewerPasswordUpdating(false);
|
||||
viewerPasswordActionRef.current = null;
|
||||
@@ -1896,6 +1550,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
clientId: hostClientId,
|
||||
shareGeneration,
|
||||
sharingPaused: false,
|
||||
qualitySettings: qualitySettingsRef.current,
|
||||
viewerPresence: true,
|
||||
viewerPasswordSettings: true,
|
||||
displayName: initialDisplayName,
|
||||
@@ -2298,14 +1953,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
: null;
|
||||
const activeCodeEntryPolicy =
|
||||
room?.codeEntryPolicy ?? creationProfile.codeEntryPolicy;
|
||||
const codecLockNotice = videoCodecLockNotice(
|
||||
phase,
|
||||
sharingPaused,
|
||||
codecTransitionPhase,
|
||||
);
|
||||
const codecResumeReady =
|
||||
codecTransitionPhase === "prepared" ||
|
||||
codecTransitionPhase === "rollback-prepared";
|
||||
const codecLockNotice = videoCodecLockNotice(phase);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -2347,11 +1995,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
type="button"
|
||||
disabled={
|
||||
switchingSource ||
|
||||
changingQuality ||
|
||||
resumePending ||
|
||||
(sharingPaused &&
|
||||
codecTransitionPhase !== null &&
|
||||
!codecResumeReady)
|
||||
changingQuality
|
||||
}
|
||||
onClick={toggleSharingPause}
|
||||
>
|
||||
@@ -2360,11 +2004,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
) : (
|
||||
<Pause size={16} fill="currentColor" aria-hidden="true" />
|
||||
)}
|
||||
{resumePending
|
||||
? "正在恢复"
|
||||
: sharingPaused
|
||||
? "恢复分享"
|
||||
: "暂停分享"}
|
||||
{sharingPaused ? "恢复分享" : "暂停分享"}
|
||||
</button>
|
||||
)}
|
||||
{phase === "live" && (
|
||||
@@ -2373,8 +2013,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
type="button"
|
||||
disabled={
|
||||
switchingSource ||
|
||||
changingQuality ||
|
||||
codecTransitionPhase !== null
|
||||
changingQuality
|
||||
}
|
||||
onClick={() => void switchSource()}
|
||||
>
|
||||
@@ -2606,14 +2245,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
switchingSource ||
|
||||
changingQuality ||
|
||||
codecTransitionPhase !== null
|
||||
changingQuality
|
||||
}
|
||||
onClick={() =>
|
||||
void changeQuality({
|
||||
...QUALITY_PROFILES[id],
|
||||
videoCodec:
|
||||
qualitySettingsRef.current.videoCodec ?? "automatic",
|
||||
qualitySettingsRef.current.videoCodec ?? "vp8",
|
||||
screenAudioQuality: resolveScreenAudioQuality(
|
||||
qualitySettingsRef.current.screenAudioQuality,
|
||||
),
|
||||
@@ -2743,18 +2381,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
key={codec}
|
||||
type="button"
|
||||
className={
|
||||
(advancedQuality.videoCodec ?? "automatic") === codec
|
||||
(advancedQuality.videoCodec ?? "vp8") === codec
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={
|
||||
(advancedQuality.videoCodec ?? "automatic") === codec
|
||||
(advancedQuality.videoCodec ?? "vp8") === codec
|
||||
}
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
(phase === "live" && !sharingPaused) ||
|
||||
changingQuality ||
|
||||
codecTransitionPhase !== null
|
||||
phase === "live" ||
|
||||
changingQuality
|
||||
}
|
||||
onClick={() =>
|
||||
changeVideoCodec(codec)
|
||||
@@ -2796,8 +2433,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
switchingSource ||
|
||||
changingQuality ||
|
||||
codecTransitionPhase === "failed"
|
||||
changingQuality
|
||||
}
|
||||
onClick={() =>
|
||||
changeScreenAudioQuality(audioQuality)
|
||||
@@ -2814,8 +2450,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
switchingSource ||
|
||||
changingQuality ||
|
||||
codecTransitionPhase !== null
|
||||
changingQuality
|
||||
}
|
||||
onClick={() => void changeQuality(advancedQuality)}
|
||||
>
|
||||
|
||||
@@ -73,10 +73,6 @@ import {
|
||||
isAutoplayPolicyRejection,
|
||||
observeCompositedVideoFrame,
|
||||
} from "../media/video-frame-proof";
|
||||
import {
|
||||
CodecProofReporter,
|
||||
type CodecProofRouteSample,
|
||||
} from "../media/codec-proof";
|
||||
import { exactPeerSignalOwner } from "../media/route-transition";
|
||||
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
|
||||
import {
|
||||
@@ -347,8 +343,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
revision: number;
|
||||
connectionId: string;
|
||||
} | null = null;
|
||||
let activePeerProofSnapshot: PeerSnapshot | null = null;
|
||||
let activeSfuProofMetrics: ConnectionMetrics | null = null;
|
||||
let endpointMediaCopyCapacity = MAX_ENDPOINT_MEDIA_CHILDREN;
|
||||
let viewerAuthorizationGeneration: string | null = null;
|
||||
let currentQualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS;
|
||||
@@ -440,67 +434,15 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const qualityEvidenceReporter = new ViewerQualityEvidenceReporter(
|
||||
(message) => active && signal.send(message),
|
||||
);
|
||||
const codecProofReporter = new CodecProofReporter(
|
||||
(message) => active && signal.send(message),
|
||||
);
|
||||
|
||||
function currentCodecProofSample(): CodecProofRouteSample | null {
|
||||
const assignment = currentRouteAssignment;
|
||||
const connectionId = currentRouteConnectionId;
|
||||
if (!assignment || !connectionId) {
|
||||
return null;
|
||||
}
|
||||
if (assignment.upstream.kind === "peer") {
|
||||
const snapshot = activePeerProofSnapshot;
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.peerId !== assignment.upstream.peerId ||
|
||||
snapshot.connectionId !== connectionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
routeRevision: currentRouteRevision,
|
||||
binding: { kind: "peer", connectionId },
|
||||
metrics: snapshot.metrics,
|
||||
};
|
||||
}
|
||||
if (
|
||||
assignment.upstream.kind !== "sfu" ||
|
||||
assignment.sfuPublicationGeneration === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
routeRevision: currentRouteRevision,
|
||||
binding: {
|
||||
kind: "sfu",
|
||||
connectionId,
|
||||
publicationGeneration: assignment.sfuPublicationGeneration,
|
||||
},
|
||||
metrics: activeSfuProofMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
function offerCodecProof(): void {
|
||||
const sample = currentCodecProofSample();
|
||||
if (sample) {
|
||||
codecProofReporter.offer(sample);
|
||||
}
|
||||
}
|
||||
|
||||
function activateProofRoute(
|
||||
function activateRouteIdentity(
|
||||
revision: number,
|
||||
assignment: ParticipantRouteAssignment,
|
||||
connectionId: string | null,
|
||||
): void {
|
||||
codecProofReporter.clear();
|
||||
currentRouteRevision = revision;
|
||||
currentRouteAssignment = assignment;
|
||||
currentRouteConnectionId =
|
||||
assignment.upstream.kind === "none" ? null : connectionId;
|
||||
activePeerProofSnapshot = null;
|
||||
activeSfuProofMetrics = null;
|
||||
pendingRouteConnection = null;
|
||||
}
|
||||
|
||||
@@ -732,8 +674,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
);
|
||||
ensureViewerRelay()?.setStream(probe.stream);
|
||||
bindRemoteStream(probe.stream, revision);
|
||||
activePeerProofSnapshot = probe.snapshot;
|
||||
offerCodecProof();
|
||||
setPeerSnapshot(probe.snapshot);
|
||||
setSfuUpstream(null);
|
||||
return true;
|
||||
@@ -813,10 +753,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
},
|
||||
onSfuUpdate: (metrics, revision) => {
|
||||
if (active && viewerSfuRoute === route) {
|
||||
if (revision === currentRouteRevision) {
|
||||
activeSfuProofMetrics = metrics;
|
||||
offerCodecProof();
|
||||
}
|
||||
if (metrics) {
|
||||
observeActiveDecodedFrames(
|
||||
"sfu",
|
||||
@@ -895,8 +831,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const route = viewerSfuRoute;
|
||||
discardPendingPeer();
|
||||
viewerSfuRoute = null;
|
||||
activeSfuProofMetrics = null;
|
||||
codecProofReporter.clear();
|
||||
setSfuUpstream(null);
|
||||
void route?.disconnect();
|
||||
}
|
||||
@@ -932,8 +866,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
|
||||
function clearUpstreamState(clearMedia = false): void {
|
||||
qualityEvidenceReporter.reset();
|
||||
codecProofReporter.clear();
|
||||
activePeerProofSnapshot = null;
|
||||
const peer = peerRef.current;
|
||||
peer?.dispose();
|
||||
peerRef.current = null;
|
||||
@@ -995,8 +927,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
probe.snapshot = snapshot;
|
||||
provePendingPeer();
|
||||
} else if (active && peerRef.current === peer) {
|
||||
activePeerProofSnapshot = snapshot;
|
||||
offerCodecProof();
|
||||
observeActiveDecodedFrames(
|
||||
"peer",
|
||||
`${probe.parentPeerId}:${snapshot.connectionId}`,
|
||||
@@ -1082,8 +1012,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
},
|
||||
onUpdate: (snapshot) => {
|
||||
if (active) {
|
||||
activePeerProofSnapshot = snapshot;
|
||||
offerCodecProof();
|
||||
observeActiveDecodedFrames(
|
||||
"peer",
|
||||
`${snapshot.peerId}:${snapshot.connectionId}`,
|
||||
@@ -1164,7 +1092,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
currentRouteRevision = nextRouteRevision;
|
||||
if (nextPeerAssisted && "routeAssignment" in message) {
|
||||
activateProofRoute(
|
||||
activateRouteIdentity(
|
||||
message.routeRevision,
|
||||
message.routeAssignment,
|
||||
message.connectionId,
|
||||
@@ -1174,12 +1102,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
message.routeAssignment.upstream,
|
||||
);
|
||||
} else {
|
||||
codecProofReporter.clear();
|
||||
currentRouteAssignment = null;
|
||||
currentRouteConnectionId = null;
|
||||
pendingRouteConnection = null;
|
||||
activePeerProofSnapshot = null;
|
||||
activeSfuProofMetrics = null;
|
||||
setAssignedRoute(null);
|
||||
dispatchPresentation({
|
||||
type: "route",
|
||||
@@ -1290,7 +1215,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
: samePeerUpstream || sameSfuUpstream
|
||||
? currentRouteConnectionId
|
||||
: null;
|
||||
activateProofRoute(
|
||||
activateRouteIdentity(
|
||||
message.revision,
|
||||
message.assignment,
|
||||
connectionId,
|
||||
@@ -1305,32 +1230,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "video-codec-prepare") {
|
||||
const accepted =
|
||||
peerAssisted &&
|
||||
currentHostPaused &&
|
||||
message.binding.kind === "peer" &&
|
||||
(await ensureViewerRelay()?.prepareVideoCodec(
|
||||
message.binding.childPeerId,
|
||||
message.binding.connectionId,
|
||||
message.generation,
|
||||
message.videoCodec,
|
||||
)) === true;
|
||||
if (active && messageAuthority.owns(authorityToken)) {
|
||||
signal.send({
|
||||
type: "video-codec-prepared",
|
||||
shareGeneration: message.shareGeneration,
|
||||
generation: message.generation,
|
||||
binding: message.binding,
|
||||
accepted,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "video-codec-proof-request") {
|
||||
codecProofReporter.request(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "route-status") {
|
||||
dispatchPresentation({
|
||||
type: "route-status",
|
||||
@@ -1458,7 +1357,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
codecProofReporter.clear();
|
||||
currentRouteAssignment = null;
|
||||
currentRouteConnectionId = null;
|
||||
pendingRouteConnection = null;
|
||||
@@ -1572,7 +1470,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
active = false;
|
||||
currentPeerId = null;
|
||||
qualityEvidenceReporter.reset();
|
||||
codecProofReporter.clear();
|
||||
clearRelayChildEvidence();
|
||||
sfuStandbyPrewarmer?.dispose();
|
||||
signal.stop();
|
||||
|
||||
@@ -95,21 +95,8 @@ export function shouldPauseLocalPreview(
|
||||
return visibilityState !== "visible" || !hasFocus;
|
||||
}
|
||||
|
||||
export const VIDEO_CODEC_TRANSITION_FAILED_NOTICE =
|
||||
"视频编码切换已失效,请停止分享后重试";
|
||||
|
||||
export function videoCodecLockNotice(
|
||||
phase: string,
|
||||
sharingPaused: boolean,
|
||||
codecTransitionPhase: string | null = null,
|
||||
): string | null {
|
||||
if (codecTransitionPhase === "failed") {
|
||||
return VIDEO_CODEC_TRANSITION_FAILED_NOTICE;
|
||||
}
|
||||
if (phase === "starting") {
|
||||
return "分享开始后,暂停分享即可切换视频编码";
|
||||
}
|
||||
return phase === "live" && !sharingPaused
|
||||
? "暂停分享后可切换视频编码"
|
||||
export function videoCodecLockNotice(phase: string): string | null {
|
||||
return phase === "starting" || phase === "live"
|
||||
? "本次分享的视频编码已固定,停止分享后可更改"
|
||||
: null;
|
||||
}
|
||||
@@ -1101,11 +1101,10 @@ function videoPublishOptions(
|
||||
profile: QualityProfile,
|
||||
): TrackPublishOptions {
|
||||
const highResolution = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
const videoCodec = profile.videoCodec ?? "vp8";
|
||||
return {
|
||||
backupCodec: false,
|
||||
...(!profile.videoCodec || profile.videoCodec === "automatic"
|
||||
? {}
|
||||
: { videoCodec: profile.videoCodec }),
|
||||
...(videoCodec === "automatic" ? {} : { videoCodec }),
|
||||
simulcast: true,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: profile.maxBitrate,
|
||||
|
||||
+31
-321
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CodecTransitionGeneration,
|
||||
IceConfig,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import type { IceConfig, SignalPayload } from "../../shared/protocol";
|
||||
import { createOpaqueId } from "../lib/opaque-id";
|
||||
import {
|
||||
audioSenderParameterWarning,
|
||||
@@ -39,39 +35,12 @@ interface HostPeerEvents {
|
||||
onUpdate: (snapshot: PeerSnapshot) => void;
|
||||
}
|
||||
|
||||
interface CodecPreparationRequest {
|
||||
generation: CodecTransitionGeneration;
|
||||
videoCodec: QualityProfile["videoCodec"];
|
||||
negotiationEpoch: number;
|
||||
promise: Promise<boolean>;
|
||||
resolve: (accepted: boolean) => void;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
interface CodecAnswerWaiter {
|
||||
request: CodecPreparationRequest;
|
||||
epoch: number;
|
||||
resolve: (accepted: boolean) => void;
|
||||
}
|
||||
|
||||
type NegotiationAnswerOwner =
|
||||
| {
|
||||
kind: "ordinary";
|
||||
epoch: number;
|
||||
}
|
||||
| {
|
||||
kind: "codec";
|
||||
epoch: number;
|
||||
waiter: CodecAnswerWaiter;
|
||||
};
|
||||
|
||||
export class HostPeer {
|
||||
readonly connectionId: string;
|
||||
|
||||
private readonly connection: RTCPeerConnection;
|
||||
private readonly pendingCandidates: SignalCandidate[] = [];
|
||||
private statsAccumulator = createStatsAccumulator();
|
||||
private videoTransceiver: RTCRtpTransceiver | null = null;
|
||||
private videoSender: RTCRtpSender | null = null;
|
||||
private audioSender: RTCRtpSender | null = null;
|
||||
private statsTimer: number | null = null;
|
||||
@@ -90,8 +59,6 @@ export class HostPeer {
|
||||
private profileRevision = 0;
|
||||
private negotiationEpoch = 0;
|
||||
private ordinaryAnswerEpoch: number | null = null;
|
||||
private codecPreparation: CodecPreparationRequest | null = null;
|
||||
private codecAnswerWaiter: CodecAnswerWaiter | null = null;
|
||||
private senderMutationTail: Promise<void> = Promise.resolve();
|
||||
private negotiationTail: Promise<void> = Promise.resolve();
|
||||
private snapshot: PeerSnapshot;
|
||||
@@ -133,13 +100,7 @@ export class HostPeer {
|
||||
direction: "sendonly",
|
||||
streams: [this.stream],
|
||||
});
|
||||
if (this.desiredProfile.videoCodec !== "automatic") {
|
||||
applyVideoCodecPreference(
|
||||
videoTransceiver,
|
||||
this.desiredProfile.videoCodec,
|
||||
);
|
||||
}
|
||||
this.videoTransceiver = videoTransceiver;
|
||||
applyVideoCodecPreference(videoTransceiver, this.desiredProfile.videoCodec);
|
||||
this.videoSender = videoTransceiver.sender;
|
||||
this.audioSender = this.connection.addTransceiver(audioTrack ?? "audio", {
|
||||
direction: "sendonly",
|
||||
@@ -271,65 +232,6 @@ export class HostPeer {
|
||||
});
|
||||
}
|
||||
|
||||
prepareVideoCodec(
|
||||
generation: CodecTransitionGeneration,
|
||||
videoCodec: QualityProfile["videoCodec"],
|
||||
): Promise<boolean> {
|
||||
const current = this.codecPreparation;
|
||||
if (
|
||||
current?.generation === generation &&
|
||||
current.videoCodec === videoCodec
|
||||
) {
|
||||
return current.promise;
|
||||
}
|
||||
if (current) {
|
||||
this.finishCodecPreparation(current, false);
|
||||
}
|
||||
|
||||
let resolve!: (accepted: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
const request: CodecPreparationRequest = {
|
||||
generation,
|
||||
videoCodec,
|
||||
negotiationEpoch: this.nextNegotiationEpoch(),
|
||||
promise,
|
||||
resolve,
|
||||
settled: false,
|
||||
};
|
||||
this.codecPreparation = request;
|
||||
void this.enqueueSenderMutation(() =>
|
||||
this.runCodecPreparation(request),
|
||||
).then(
|
||||
(accepted) => this.finishCodecPreparation(request, accepted),
|
||||
() => this.finishCodecPreparation(request, false),
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
cancelVideoCodecPreparation(): void {
|
||||
const request = this.codecPreparation;
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
this.finishCodecPreparation(request, false);
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const epoch = this.nextNegotiationEpoch();
|
||||
void this.enqueueNegotiation(async () => {
|
||||
if (
|
||||
this.disposed ||
|
||||
epoch !== this.negotiationEpoch ||
|
||||
this.codecPreparation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.rollbackPendingLocalOffer();
|
||||
});
|
||||
}
|
||||
|
||||
async acceptSignal(payload: SignalPayload): Promise<void> {
|
||||
if (this.disposed || payload.connectionId !== this.connectionId) {
|
||||
return;
|
||||
@@ -352,34 +254,15 @@ export class HostPeer {
|
||||
}
|
||||
|
||||
async restartIce(): Promise<boolean> {
|
||||
while (!this.disposed) {
|
||||
const codecPreparation = this.codecPreparation;
|
||||
if (codecPreparation) {
|
||||
await codecPreparation.promise;
|
||||
continue;
|
||||
return this.enqueueNegotiation(async () => {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.connection.signalingState !== "stable"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const restarted = await this.enqueueNegotiation(async () => {
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
if (this.codecPreparation) {
|
||||
return null;
|
||||
}
|
||||
if (this.connection.signalingState !== "stable") {
|
||||
return false;
|
||||
}
|
||||
const accepted = await this.createOwnedOffer(
|
||||
true,
|
||||
this.nextNegotiationEpoch(),
|
||||
null,
|
||||
);
|
||||
return !accepted && this.codecPreparation ? null : accepted;
|
||||
});
|
||||
if (restarted !== null) {
|
||||
return restarted;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return this.createOwnedOffer(true, this.nextNegotiationEpoch());
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
@@ -414,7 +297,6 @@ export class HostPeer {
|
||||
window.clearInterval(this.statsTimer);
|
||||
this.statsTimer = null;
|
||||
}
|
||||
this.cancelVideoCodecPreparation();
|
||||
this.connection.close();
|
||||
}
|
||||
|
||||
@@ -457,24 +339,18 @@ export class HostPeer {
|
||||
|
||||
private createOffer(restart: boolean): Promise<boolean> {
|
||||
return this.enqueueNegotiation(async () => {
|
||||
if (this.disposed || this.codecPreparation) {
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
return this.createOwnedOffer(
|
||||
restart,
|
||||
this.nextNegotiationEpoch(),
|
||||
null,
|
||||
);
|
||||
return this.createOwnedOffer(restart, this.nextNegotiationEpoch());
|
||||
});
|
||||
}
|
||||
|
||||
private async createOwnedOffer(
|
||||
restart: boolean,
|
||||
epoch: number,
|
||||
negotiationGeneration: CodecTransitionGeneration | null,
|
||||
request: CodecPreparationRequest | null = null,
|
||||
): Promise<boolean> {
|
||||
if (!this.ownsLocalOffer(epoch, request)) {
|
||||
if (!this.ownsLocalOffer(epoch)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -482,24 +358,21 @@ export class HostPeer {
|
||||
this.connection.restartIce();
|
||||
}
|
||||
const offer = await this.connection.createOffer();
|
||||
if (!this.ownsLocalOffer(epoch, request)) {
|
||||
if (!this.ownsLocalOffer(epoch)) {
|
||||
return false;
|
||||
}
|
||||
await this.connection.setLocalDescription(offer);
|
||||
if (
|
||||
!this.ownsLocalOffer(epoch, request) ||
|
||||
!this.ownsLocalOffer(epoch) ||
|
||||
!this.connection.localDescription
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (negotiationGeneration === null) {
|
||||
this.ordinaryAnswerEpoch = epoch;
|
||||
}
|
||||
this.ordinaryAnswerEpoch = epoch;
|
||||
if (
|
||||
!this.events.sendSignal(this.peerId, {
|
||||
kind: "description",
|
||||
connectionId: this.connectionId,
|
||||
negotiationGeneration,
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: this.connection.localDescription.sdp,
|
||||
@@ -512,13 +385,10 @@ export class HostPeer {
|
||||
this.emit();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.ownsLocalOffer(epoch, request)) {
|
||||
if (!this.ownsLocalOffer(epoch)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
negotiationGeneration === null &&
|
||||
this.ordinaryAnswerEpoch === epoch
|
||||
) {
|
||||
if (this.ordinaryAnswerEpoch === epoch) {
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
}
|
||||
this.setError(error, restart ? "恢复连接失败" : "创建连接失败");
|
||||
@@ -536,206 +406,46 @@ export class HostPeer {
|
||||
private async acceptAnswer(
|
||||
payload: Extract<SignalPayload, { kind: "description" }>,
|
||||
): Promise<void> {
|
||||
const owner = this.currentAnswerOwner(payload.negotiationGeneration);
|
||||
if (!owner) {
|
||||
const epoch = this.ordinaryAnswerEpoch;
|
||||
if (epoch === null || !this.ownsAnswer(epoch)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.connection.setRemoteDescription(payload.description);
|
||||
if (!this.ownsAnswer(owner)) {
|
||||
if (!this.ownsAnswer(epoch)) {
|
||||
return;
|
||||
}
|
||||
await this.flushCandidates();
|
||||
if (this.ownsAnswer(owner)) {
|
||||
this.completeAnswer(owner, true);
|
||||
if (this.ownsAnswer(epoch)) {
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.ownsAnswer(owner)) {
|
||||
if (!this.ownsAnswer(epoch)) {
|
||||
return;
|
||||
}
|
||||
this.completeAnswer(owner, false);
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private currentAnswerOwner(
|
||||
generation: CodecTransitionGeneration | null,
|
||||
): NegotiationAnswerOwner | null {
|
||||
if (generation === null) {
|
||||
const epoch = this.ordinaryAnswerEpoch;
|
||||
return epoch !== null && epoch === this.negotiationEpoch
|
||||
? { kind: "ordinary", epoch }
|
||||
: null;
|
||||
}
|
||||
const waiter = this.codecAnswerWaiter;
|
||||
return waiter &&
|
||||
waiter.epoch === this.negotiationEpoch &&
|
||||
waiter.request.generation === generation &&
|
||||
this.ownsCodecPreparation(waiter.request)
|
||||
? { kind: "codec", epoch: waiter.epoch, waiter }
|
||||
: null;
|
||||
}
|
||||
|
||||
private ownsAnswer(owner: NegotiationAnswerOwner): boolean {
|
||||
if (this.disposed || owner.epoch !== this.negotiationEpoch) {
|
||||
return false;
|
||||
}
|
||||
return owner.kind === "ordinary"
|
||||
? this.ordinaryAnswerEpoch === owner.epoch
|
||||
: this.codecAnswerWaiter === owner.waiter &&
|
||||
this.ownsCodecPreparation(owner.waiter.request);
|
||||
}
|
||||
|
||||
private completeAnswer(
|
||||
owner: NegotiationAnswerOwner,
|
||||
accepted: boolean,
|
||||
): void {
|
||||
if (!this.ownsAnswer(owner)) {
|
||||
return;
|
||||
}
|
||||
if (owner.kind === "ordinary") {
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
return;
|
||||
}
|
||||
this.codecAnswerWaiter = null;
|
||||
owner.waiter.resolve(accepted);
|
||||
}
|
||||
|
||||
private settleCodecAnswer(
|
||||
accepted: boolean,
|
||||
generation?: CodecTransitionGeneration,
|
||||
epoch?: number,
|
||||
): void {
|
||||
const waiter = this.codecAnswerWaiter;
|
||||
if (
|
||||
(generation !== undefined &&
|
||||
waiter?.request.generation !== generation) ||
|
||||
(epoch !== undefined && waiter?.epoch !== epoch)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.codecAnswerWaiter = null;
|
||||
waiter?.resolve(accepted);
|
||||
}
|
||||
|
||||
private async runCodecPreparation(
|
||||
request: CodecPreparationRequest,
|
||||
): Promise<boolean> {
|
||||
const negotiation = await this.enqueueNegotiation(async () => {
|
||||
const transceiver = this.videoTransceiver;
|
||||
if (
|
||||
!this.ownsCodecPreparation(request) ||
|
||||
request.negotiationEpoch !== this.negotiationEpoch ||
|
||||
!transceiver ||
|
||||
!(await this.rollbackPendingLocalOffer()) ||
|
||||
!this.ownsCodecPreparation(request) ||
|
||||
request.negotiationEpoch !== this.negotiationEpoch ||
|
||||
!applyVideoCodecPreference(transceiver, request.videoCodec)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const answer = new Promise<boolean>((resolve) => {
|
||||
this.codecAnswerWaiter = {
|
||||
request,
|
||||
epoch: request.negotiationEpoch,
|
||||
resolve,
|
||||
};
|
||||
});
|
||||
if (
|
||||
!(await this.createOwnedOffer(
|
||||
false,
|
||||
request.negotiationEpoch,
|
||||
request.generation,
|
||||
request,
|
||||
))
|
||||
) {
|
||||
this.settleCodecAnswer(
|
||||
false,
|
||||
request.generation,
|
||||
request.negotiationEpoch,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { answer };
|
||||
});
|
||||
if (!negotiation) {
|
||||
return false;
|
||||
}
|
||||
const accepted = await negotiation.answer;
|
||||
if (!accepted || !this.ownsCodecPreparation(request)) {
|
||||
return false;
|
||||
}
|
||||
this.desiredProfile = {
|
||||
...this.desiredProfile,
|
||||
videoCodec: request.videoCodec,
|
||||
};
|
||||
this.appliedVideoProfile = this.appliedVideoProfile
|
||||
? { ...this.appliedVideoProfile, videoCodec: request.videoCodec }
|
||||
: { ...this.desiredProfile };
|
||||
return true;
|
||||
}
|
||||
|
||||
private ownsLocalOffer(
|
||||
epoch: number,
|
||||
request: CodecPreparationRequest | null,
|
||||
): boolean {
|
||||
private ownsAnswer(epoch: number): boolean {
|
||||
return (
|
||||
!this.disposed &&
|
||||
epoch === this.negotiationEpoch &&
|
||||
(request === null ||
|
||||
(request.negotiationEpoch === epoch &&
|
||||
this.ownsCodecPreparation(request)))
|
||||
this.ordinaryAnswerEpoch === epoch
|
||||
);
|
||||
}
|
||||
|
||||
private ownsLocalOffer(epoch: number): boolean {
|
||||
return !this.disposed && epoch === this.negotiationEpoch;
|
||||
}
|
||||
|
||||
private nextNegotiationEpoch(): number {
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
this.negotiationEpoch += 1;
|
||||
return this.negotiationEpoch;
|
||||
}
|
||||
|
||||
private ownsCodecPreparation(request: CodecPreparationRequest): boolean {
|
||||
return (
|
||||
!this.disposed &&
|
||||
!request.settled &&
|
||||
this.codecPreparation === request
|
||||
);
|
||||
}
|
||||
|
||||
private finishCodecPreparation(
|
||||
request: CodecPreparationRequest,
|
||||
accepted: boolean,
|
||||
): void {
|
||||
if (request.settled) {
|
||||
return;
|
||||
}
|
||||
request.settled = true;
|
||||
if (this.codecAnswerWaiter?.request === request) {
|
||||
const waiter = this.codecAnswerWaiter;
|
||||
this.codecAnswerWaiter = null;
|
||||
waiter.resolve(false);
|
||||
}
|
||||
if (this.codecPreparation === request) {
|
||||
this.codecPreparation = null;
|
||||
}
|
||||
request.resolve(accepted);
|
||||
}
|
||||
|
||||
private async rollbackPendingLocalOffer(): Promise<boolean> {
|
||||
if (this.connection.signalingState === "stable") {
|
||||
return true;
|
||||
}
|
||||
if (this.connection.signalingState !== "have-local-offer") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.connection.setLocalDescription({ type: "rollback" });
|
||||
return (this.connection.signalingState as RTCSignalingState) === "stable";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateStats(): Promise<void> {
|
||||
if (
|
||||
this.disposed ||
|
||||
|
||||
@@ -11,7 +11,7 @@ export function applyVideoCodecPreference(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
preference: VideoCodecPreference | undefined,
|
||||
): boolean {
|
||||
const resolvedPreference = preference ?? "automatic";
|
||||
const resolvedPreference = preference ?? "vp8";
|
||||
if (typeof transceiver.setCodecPreferences !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CodecTransitionGeneration,
|
||||
IceConfig,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import type { IceConfig, SignalPayload } from "../../shared/protocol";
|
||||
import {
|
||||
EMPTY_METRICS,
|
||||
type PeerSnapshot,
|
||||
@@ -65,9 +61,6 @@ export class ViewerPeer {
|
||||
private currentIceConfig: PeerIceConfig;
|
||||
private snapshot: PeerSnapshot | null = null;
|
||||
private descriptionTail: Promise<void> = Promise.resolve();
|
||||
private latestCodecNegotiationGeneration:
|
||||
| CodecTransitionGeneration
|
||||
| null = null;
|
||||
|
||||
constructor(
|
||||
iceConfig: PeerIceConfig,
|
||||
@@ -98,14 +91,6 @@ export class ViewerPeer {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
const generation = payload.negotiationGeneration;
|
||||
if (generation !== null) {
|
||||
const latest = this.latestCodecNegotiationGeneration;
|
||||
if (latest !== null && generation <= latest) {
|
||||
return;
|
||||
}
|
||||
this.latestCodecNegotiationGeneration = generation;
|
||||
}
|
||||
return this.enqueueDescription(() =>
|
||||
this.acceptDescription(parentPeerId, connection, payload),
|
||||
);
|
||||
@@ -194,7 +179,6 @@ export class ViewerPeer {
|
||||
this.parentPeerId = parentPeerId;
|
||||
this.connectionId = connectionId;
|
||||
this.descriptionTail = Promise.resolve();
|
||||
this.latestCodecNegotiationGeneration = null;
|
||||
this.remoteStream = new MediaStream();
|
||||
this.statsAccumulator = createStatsAccumulator();
|
||||
|
||||
@@ -285,22 +269,21 @@ export class ViewerPeer {
|
||||
payload: Extract<SignalPayload, { kind: "description" }>,
|
||||
): Promise<void> {
|
||||
const connectionId = payload.connectionId;
|
||||
const generation = payload.negotiationGeneration;
|
||||
try {
|
||||
await connection.setRemoteDescription(payload.description);
|
||||
if (!this.ownsDescription(connection, connectionId, generation)) {
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
await this.flushCandidates(connection, connectionId);
|
||||
if (!this.ownsDescription(connection, connectionId, generation)) {
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
const answer = preferScreenAudioStereo(await connection.createAnswer());
|
||||
if (!this.ownsDescription(connection, connectionId, generation)) {
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
await connection.setLocalDescription(answer);
|
||||
if (!this.ownsDescription(connection, connectionId, generation)) {
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
if (!connection.localDescription) {
|
||||
@@ -309,7 +292,6 @@ export class ViewerPeer {
|
||||
if (!this.events.sendSignal(parentPeerId, {
|
||||
kind: "description",
|
||||
connectionId,
|
||||
negotiationGeneration: generation,
|
||||
description: {
|
||||
type: "answer",
|
||||
sdp: connection.localDescription.sdp,
|
||||
@@ -320,7 +302,7 @@ export class ViewerPeer {
|
||||
this.offerRecoveryAttempts = 0;
|
||||
this.scheduleInitialConnectionDeadline(connection, connectionId);
|
||||
} catch (error) {
|
||||
if (!this.ownsDescription(connection, connectionId, generation)) {
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
this.setError(error, "处理分享端信令失败");
|
||||
@@ -361,18 +343,6 @@ export class ViewerPeer {
|
||||
}
|
||||
}
|
||||
|
||||
private ownsDescription(
|
||||
connection: RTCPeerConnection,
|
||||
connectionId: string,
|
||||
generation: CodecTransitionGeneration | null,
|
||||
): boolean {
|
||||
return (
|
||||
this.isCurrentConnection(connection, connectionId) &&
|
||||
(generation === null ||
|
||||
this.latestCodecNegotiationGeneration === generation)
|
||||
);
|
||||
}
|
||||
|
||||
private handleConnectionState(state: RTCPeerConnectionState): void {
|
||||
if (state === "connected") {
|
||||
this.clearDisconnectTimer();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
CodecTransitionGeneration,
|
||||
IceConfig,
|
||||
PreparedRouteCandidate,
|
||||
SignalPayload,
|
||||
@@ -193,24 +192,6 @@ export class ViewerRelay {
|
||||
return result.catch(() => false);
|
||||
}
|
||||
|
||||
prepareVideoCodec(
|
||||
childPeerId: string,
|
||||
connectionId: string,
|
||||
generation: CodecTransitionGeneration,
|
||||
videoCodec: QualityProfile["videoCodec"],
|
||||
): Promise<boolean> {
|
||||
const peer = this.peers.get(childPeerId);
|
||||
if (
|
||||
this.disposed ||
|
||||
!peer ||
|
||||
peer.connectionId !== connectionId ||
|
||||
!this.childPeerIds.includes(childPeerId)
|
||||
) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return peer.prepareVideoCodec(generation, videoCodec);
|
||||
}
|
||||
|
||||
getSignalRouteRevision(
|
||||
fromPeerId: string,
|
||||
connectionId: string,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,16 +2,12 @@ import { randomBytes } from "node:crypto";
|
||||
|
||||
import type {
|
||||
ClientMessage,
|
||||
CodecPreparationBinding,
|
||||
CodecProofBinding,
|
||||
CodecTransitionGeneration,
|
||||
MediaAssignment,
|
||||
MediaRouteUpstream,
|
||||
ParticipantRouteAssignment,
|
||||
PreparedRouteCandidate,
|
||||
Role,
|
||||
ServerMessage,
|
||||
VideoCodecPreference,
|
||||
} from "../shared/protocol.js";
|
||||
import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js";
|
||||
import type { SfuTokenIssuer } from "./livekit-token.js";
|
||||
@@ -21,8 +17,6 @@ import {
|
||||
type CandidatePlan,
|
||||
type CandidateReservation,
|
||||
type CandidateTuple,
|
||||
type CodecOperationGuard,
|
||||
type EdgeGuard,
|
||||
type OperationSnapshot,
|
||||
type RouteSnapshot,
|
||||
} from "./room-route-controller.js";
|
||||
@@ -85,15 +79,6 @@ interface RoomRuntime {
|
||||
pump?: Promise<void>;
|
||||
deadlineTimer?: NodeJS.Timeout;
|
||||
refreshKeys: Set<string>;
|
||||
codecOperation?: CodecOperationRuntime;
|
||||
codecDrainBarrierKey?: string;
|
||||
}
|
||||
|
||||
interface CodecOperationRuntime {
|
||||
generation: CodecTransitionGeneration;
|
||||
guard: CodecOperationGuard;
|
||||
drainBeforePreparationKey?: string;
|
||||
resolvePreparation?: (snapshot: CodecRouteSnapshot | null) => void;
|
||||
}
|
||||
|
||||
interface PreparedCandidate {
|
||||
@@ -106,10 +91,6 @@ interface PreparedCandidate {
|
||||
viewerSfuConfig?: Extract<ServerMessage, { type: "sfu-config" }>;
|
||||
}
|
||||
|
||||
type ResourceWaiter =
|
||||
| { kind: "admission" }
|
||||
| { kind: "drain"; drainKey: string };
|
||||
|
||||
type PrepareResult =
|
||||
| {
|
||||
kind: "denied";
|
||||
@@ -161,24 +142,9 @@ export interface AuthenticatedRouteParticipant {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface CodecRouteTarget {
|
||||
viewerPeerId: string;
|
||||
viewerSessionId: string;
|
||||
preparationOwnerSessionId: string;
|
||||
preparationBinding: CodecPreparationBinding;
|
||||
proofBinding: CodecProofBinding;
|
||||
routeRevision: number;
|
||||
proofReady: boolean;
|
||||
}
|
||||
|
||||
export interface CodecRouteSnapshot {
|
||||
revision: number;
|
||||
targets: readonly CodecRouteTarget[];
|
||||
}
|
||||
|
||||
export class HybridMediaRouter {
|
||||
private readonly rooms = new Map<string, RoomRuntime>();
|
||||
private readonly resourceWaiters = new Map<string, ResourceWaiter>();
|
||||
private readonly resourceWaiters = new Set<string>();
|
||||
private readonly sfuDrainTasks = new Map<string, SfuDrainTask>();
|
||||
private readonly hostOfflineChecks = new Map<string, HostOfflineCheck>();
|
||||
private readonly now: () => number;
|
||||
@@ -312,311 +278,6 @@ export class HybridMediaRouter {
|
||||
: { kind: "none" };
|
||||
}
|
||||
|
||||
codecRouteSnapshot(roomId: string): CodecRouteSnapshot | null {
|
||||
const room = this.rooms.get(roomId);
|
||||
const snapshot = room?.controller?.snapshot();
|
||||
if (!room || !snapshot) {
|
||||
return null;
|
||||
}
|
||||
const targets: CodecRouteTarget[] = [];
|
||||
for (const [viewerPeerId, edge] of snapshot.upstreamByViewer) {
|
||||
if (!edge.usable || !edge.physicalActive) {
|
||||
continue;
|
||||
}
|
||||
const viewer = this.options.roomStore.getConnectedViewer(
|
||||
roomId,
|
||||
viewerPeerId,
|
||||
);
|
||||
if (viewer?.sessionId !== edge.childSessionId) {
|
||||
continue;
|
||||
}
|
||||
const pathIsPhysical = this.pathIsPhysical(
|
||||
roomId,
|
||||
snapshot,
|
||||
viewerPeerId,
|
||||
);
|
||||
if (edge.kind === "peer") {
|
||||
const parent = this.connectedPeer(roomId, edge.parentPeerId);
|
||||
if (parent?.sessionId !== edge.parentSessionId) {
|
||||
continue;
|
||||
}
|
||||
targets.push({
|
||||
viewerPeerId,
|
||||
viewerSessionId: edge.childSessionId,
|
||||
preparationOwnerSessionId: edge.parentSessionId,
|
||||
preparationBinding: {
|
||||
kind: "peer",
|
||||
childPeerId: viewerPeerId,
|
||||
connectionId: edge.connectionId,
|
||||
},
|
||||
proofBinding: {
|
||||
kind: "peer",
|
||||
connectionId: edge.connectionId,
|
||||
},
|
||||
routeRevision: snapshot.revision,
|
||||
proofReady: pathIsPhysical,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const publication = snapshot.hostPublication;
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (
|
||||
pathIsPhysical &&
|
||||
publication?.usable &&
|
||||
publication.physicalActive &&
|
||||
publication.generation === edge.publicationGeneration &&
|
||||
host?.sessionId === publication.hostSessionId
|
||||
) {
|
||||
targets.push({
|
||||
viewerPeerId,
|
||||
viewerSessionId: edge.childSessionId,
|
||||
preparationOwnerSessionId: publication.hostSessionId,
|
||||
preparationBinding: {
|
||||
kind: "sfu",
|
||||
publicationGeneration: edge.publicationGeneration,
|
||||
},
|
||||
proofBinding: {
|
||||
kind: "sfu",
|
||||
connectionId: edge.connectionId,
|
||||
publicationGeneration: edge.publicationGeneration,
|
||||
},
|
||||
routeRevision: snapshot.revision,
|
||||
proofReady: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const operation = snapshot.operation;
|
||||
const current = operation?.current;
|
||||
if (
|
||||
operation?.owner.kind === "codec" &&
|
||||
current?.tuple.kind === "sfu"
|
||||
) {
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
const viewer = this.options.roomStore.getConnectedViewer(
|
||||
roomId,
|
||||
operation.childPeerId,
|
||||
);
|
||||
if (
|
||||
host?.sessionId === operation.owner.hostSessionId &&
|
||||
viewer?.sessionId === operation.childSessionId
|
||||
) {
|
||||
const target: CodecRouteTarget = {
|
||||
viewerPeerId: operation.childPeerId,
|
||||
viewerSessionId: operation.childSessionId,
|
||||
preparationOwnerSessionId: host.sessionId,
|
||||
preparationBinding: {
|
||||
kind: "sfu",
|
||||
publicationGeneration: operation.owner.publicationGeneration,
|
||||
},
|
||||
proofBinding: {
|
||||
kind: "sfu",
|
||||
connectionId: operation.owner.connectionId,
|
||||
publicationGeneration: operation.owner.publicationGeneration,
|
||||
},
|
||||
routeRevision: current.revision,
|
||||
proofReady: false,
|
||||
};
|
||||
const existingIndex = targets.findIndex(
|
||||
(candidate) => candidate.viewerPeerId === operation.childPeerId,
|
||||
);
|
||||
if (existingIndex === -1) targets.push(target);
|
||||
else targets[existingIndex] = target;
|
||||
}
|
||||
}
|
||||
return { revision: snapshot.revision, targets };
|
||||
}
|
||||
|
||||
beginCodecSfuReplacement(input: {
|
||||
roomId: string;
|
||||
hostSessionId: string;
|
||||
generation: CodecTransitionGeneration;
|
||||
videoCodec: VideoCodecPreference;
|
||||
timeoutMs: number;
|
||||
}): Promise<CodecRouteSnapshot | null> {
|
||||
const room = this.rooms.get(input.roomId);
|
||||
const controller = room?.controller;
|
||||
const snapshot = controller?.snapshot();
|
||||
if (
|
||||
!room ||
|
||||
!controller ||
|
||||
!snapshot ||
|
||||
room.codecOperation ||
|
||||
!this.options.sfuFallback
|
||||
) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const nowMs = this.now();
|
||||
const publicationGeneration = opaqueId();
|
||||
const connectionId = opaqueId();
|
||||
const publicationConnectionId = opaqueId();
|
||||
const begun = controller.beginCodecSfuPublication({
|
||||
routeRevision: snapshot.revision,
|
||||
hostSessionId: input.hostSessionId,
|
||||
codecGeneration: input.generation,
|
||||
videoCodec: input.videoCodec,
|
||||
publicationGeneration,
|
||||
connectionId,
|
||||
publicationConnectionId,
|
||||
nowMs,
|
||||
deadlineAtMs: nowMs + input.timeoutMs,
|
||||
});
|
||||
if (!begun.accepted || !begun.guard) {
|
||||
this.releaseResources(begun.released);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
let resolvePreparation!: (snapshot: CodecRouteSnapshot | null) => void;
|
||||
const preparation = new Promise<CodecRouteSnapshot | null>((resolve) => {
|
||||
resolvePreparation = resolve;
|
||||
});
|
||||
const carriedDrainBarrierKey = room.codecDrainBarrierKey;
|
||||
const drainBeforePreparationKey =
|
||||
carriedDrainBarrierKey ??
|
||||
(snapshot.hostPublication?.resource.kind === "sfu-publication"
|
||||
? managedSfuRoomName(snapshot.hostPublication.resource.fence)
|
||||
: undefined);
|
||||
room.codecDrainBarrierKey = undefined;
|
||||
room.codecOperation = {
|
||||
generation: input.generation,
|
||||
guard: begun.guard,
|
||||
...(drainBeforePreparationKey ? { drainBeforePreparationKey } : {}),
|
||||
resolvePreparation,
|
||||
};
|
||||
this.releaseResources(begun.released);
|
||||
if (controller.snapshot().revision !== snapshot.revision) {
|
||||
this.broadcastActive(input.roomId, room);
|
||||
}
|
||||
this.requestPump(input.roomId);
|
||||
return preparation;
|
||||
}
|
||||
|
||||
parkCodecSfuReplacement(
|
||||
roomId: string,
|
||||
generation: CodecTransitionGeneration,
|
||||
): boolean {
|
||||
const room = this.rooms.get(roomId);
|
||||
const runtime = room?.codecOperation;
|
||||
const controller = room?.controller;
|
||||
const operation = controller?.snapshot().operation;
|
||||
if (
|
||||
!room ||
|
||||
!runtime ||
|
||||
runtime.generation !== generation ||
|
||||
!controller ||
|
||||
operation?.owner.kind !== "codec"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expectedPhase = operation.owner.phase;
|
||||
if (expectedPhase !== "preparing" && expectedPhase !== "proving") {
|
||||
return expectedPhase === "prepared";
|
||||
}
|
||||
const parked = controller.transitionCodecOperation({
|
||||
guard: runtime.guard,
|
||||
expectedPhase,
|
||||
phase: "prepared",
|
||||
});
|
||||
this.releaseResources(parked.released);
|
||||
this.clearDeadline(room);
|
||||
return parked.accepted;
|
||||
}
|
||||
|
||||
beginCodecSfuProof(
|
||||
roomId: string,
|
||||
generation: CodecTransitionGeneration,
|
||||
timeoutMs: number,
|
||||
): boolean {
|
||||
const room = this.rooms.get(roomId);
|
||||
const runtime = room?.codecOperation;
|
||||
const controller = room?.controller;
|
||||
if (!room || !runtime || runtime.generation !== generation || !controller) {
|
||||
return false;
|
||||
}
|
||||
const nowMs = this.now();
|
||||
const proving = controller.transitionCodecOperation({
|
||||
guard: runtime.guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs,
|
||||
deadlineAtMs: nowMs + timeoutMs,
|
||||
});
|
||||
this.releaseResources(proving.released);
|
||||
this.clearDeadline(room);
|
||||
return proving.accepted;
|
||||
}
|
||||
|
||||
abortCodecSfuReplacement(
|
||||
roomId: string,
|
||||
generation: CodecTransitionGeneration,
|
||||
): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
const runtime = room?.codecOperation;
|
||||
const controller = room?.controller;
|
||||
if (!room || !runtime || runtime.generation !== generation || !controller) {
|
||||
return;
|
||||
}
|
||||
const before = controller.snapshot().revision;
|
||||
const aborted = controller.abortCodecOperation(runtime.guard, this.now());
|
||||
this.releaseResources(aborted.released);
|
||||
this.carryCodecDrainBarrier(room);
|
||||
this.resourceWaiters.delete(roomId);
|
||||
this.settleCodecPreparation(room, null);
|
||||
room.codecOperation = undefined;
|
||||
this.clearDeadline(room);
|
||||
if (controller.snapshot().revision !== before) {
|
||||
this.broadcastActive(roomId, room);
|
||||
}
|
||||
}
|
||||
|
||||
routeWaitInvalidCodecTargets(
|
||||
roomId: string,
|
||||
viewerPeerIds: readonly string[],
|
||||
): number | null {
|
||||
const room = this.rooms.get(roomId);
|
||||
const controller = room?.controller;
|
||||
const snapshot = controller?.snapshot();
|
||||
if (!room || !controller || !snapshot) return null;
|
||||
|
||||
const guards: EdgeGuard[] = [];
|
||||
for (const viewerPeerId of new Set(viewerPeerIds)) {
|
||||
const viewer = this.options.roomStore.getConnectedViewer(
|
||||
roomId,
|
||||
viewerPeerId,
|
||||
);
|
||||
const edge = snapshot.upstreamByViewer.get(viewerPeerId);
|
||||
if (
|
||||
!viewer ||
|
||||
!edge?.physicalActive ||
|
||||
edge.childSessionId !== viewer.sessionId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
guards.push({
|
||||
childPeerId: viewerPeerId,
|
||||
childSessionId: viewer.sessionId,
|
||||
routeRevision: snapshot.revision,
|
||||
connectionId: edge.connectionId,
|
||||
...(edge.kind === "peer"
|
||||
? { parentSessionId: edge.parentSessionId }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (guards.length === 0) return snapshot.revision;
|
||||
|
||||
const retired = controller.retireCommittedTransportsForRouteWait(
|
||||
guards,
|
||||
this.now(),
|
||||
);
|
||||
if (!retired.accepted) {
|
||||
throw new Error("Codec route targets could not enter route-wait");
|
||||
}
|
||||
this.releaseResources(retired.released);
|
||||
if (retired.activeRevision !== snapshot.revision) {
|
||||
this.broadcastActive(roomId, room);
|
||||
}
|
||||
this.requestPump(roomId);
|
||||
return retired.activeRevision;
|
||||
}
|
||||
|
||||
viewerSfuMediaStateIsCurrent(
|
||||
roomId: string,
|
||||
viewerPeerId: string,
|
||||
@@ -748,10 +409,6 @@ export class HybridMediaRouter {
|
||||
return;
|
||||
}
|
||||
const before = controller.snapshot().revision;
|
||||
const codecGeneration =
|
||||
operation.owner.kind === "codec"
|
||||
? operation.owner.codecGeneration
|
||||
: undefined;
|
||||
const settled = controller.candidateReady(
|
||||
{
|
||||
childPeerId: participant.peerId,
|
||||
@@ -770,13 +427,6 @@ export class HybridMediaRouter {
|
||||
participant.peerId,
|
||||
current.connectionId,
|
||||
);
|
||||
if (
|
||||
codecGeneration !== undefined &&
|
||||
room.codecOperation?.generation === codecGeneration
|
||||
) {
|
||||
this.settleCodecPreparation(room, null);
|
||||
room.codecOperation = undefined;
|
||||
}
|
||||
}
|
||||
if (controller.snapshot().revision !== before) this.broadcastActive(participant.roomId, room);
|
||||
if (settled.exhausted) {
|
||||
@@ -845,9 +495,7 @@ export class HybridMediaRouter {
|
||||
participant.role === "host" &&
|
||||
participant.peerId === room.hostPeerId &&
|
||||
this.options.roomStore.getConnectedHost(participant.roomId)
|
||||
?.sessionId === participant.sessionId &&
|
||||
(operation.owner.kind !== "codec" ||
|
||||
operation.owner.hostSessionId === participant.sessionId);
|
||||
?.sessionId === participant.sessionId;
|
||||
if (!ownsChild && !ownsParent && !ownsPublication) return;
|
||||
if (message.connectionId && message.connectionId !== current.connectionId) return;
|
||||
const before = snapshot.revision;
|
||||
@@ -1021,10 +669,6 @@ export class HybridMediaRouter {
|
||||
if (room.requested && !this.closing) this.requestPump(roomId);
|
||||
}
|
||||
})().catch(() => {
|
||||
const generation = room.codecOperation?.generation;
|
||||
if (generation !== undefined) {
|
||||
this.abortCodecSfuReplacement(roomId, generation);
|
||||
}
|
||||
console.error("Media route reconciliation failed");
|
||||
this.sendRoomError(roomId, "SERVER_ERROR", "Media routing failed");
|
||||
});
|
||||
@@ -1050,20 +694,11 @@ export class HybridMediaRouter {
|
||||
if (!operation) {
|
||||
this.clearDeadline(room);
|
||||
this.resourceWaiters.delete(roomId);
|
||||
if (room.codecOperation) {
|
||||
this.settleCodecPreparation(room, null);
|
||||
this.carryCodecDrainBarrier(room);
|
||||
room.codecOperation = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (operation.current) {
|
||||
this.resourceWaiters.delete(roomId);
|
||||
if (operation.owner.kind === "route") {
|
||||
this.scheduleDeadline(roomId, room, operation);
|
||||
} else {
|
||||
this.clearDeadline(room);
|
||||
}
|
||||
this.scheduleDeadline(roomId, room, operation);
|
||||
return;
|
||||
}
|
||||
const plan = operation.candidates[operation.cursor];
|
||||
@@ -1071,23 +706,9 @@ export class HybridMediaRouter {
|
||||
this.resourceWaiters.delete(roomId);
|
||||
return;
|
||||
}
|
||||
if (operation.owner.kind === "codec") {
|
||||
const runtime = room.codecOperation;
|
||||
const drainKey = runtime?.drainBeforePreparationKey;
|
||||
if (
|
||||
runtime?.generation === operation.owner.codecGeneration &&
|
||||
drainKey &&
|
||||
this.sfuDrainTasks.has(drainKey)
|
||||
) {
|
||||
this.resourceWaiters.set(roomId, { kind: "drain", drainKey });
|
||||
this.clearDeadline(room);
|
||||
return;
|
||||
}
|
||||
if (runtime) runtime.drainBeforePreparationKey = undefined;
|
||||
}
|
||||
const guard = cursorGuard(operation, plan);
|
||||
if (plan.tuple.kind === "sfu") {
|
||||
this.resourceWaiters.set(roomId, { kind: "admission" });
|
||||
this.resourceWaiters.add(roomId);
|
||||
} else {
|
||||
this.resourceWaiters.delete(roomId);
|
||||
}
|
||||
@@ -1103,18 +724,6 @@ export class HybridMediaRouter {
|
||||
if (preparation.rejectionBucket !== "sfu-admission") {
|
||||
this.resourceWaiters.delete(roomId);
|
||||
}
|
||||
if (operation.owner.kind === "codec") {
|
||||
if (preparation.rejectionBucket === "sfu-admission") {
|
||||
this.resourceWaiters.set(roomId, { kind: "admission" });
|
||||
this.clearDeadline(room);
|
||||
return;
|
||||
}
|
||||
this.abortCodecSfuReplacement(
|
||||
roomId,
|
||||
operation.owner.codecGeneration,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (preparation.rejectionBucket === "sfu-admission") {
|
||||
if (
|
||||
!controller.noteCurrentCandidateRejection(
|
||||
@@ -1124,7 +733,7 @@ export class HybridMediaRouter {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.resourceWaiters.set(roomId, { kind: "admission" });
|
||||
this.resourceWaiters.add(roomId);
|
||||
this.sendViewerRouteStatus(roomId, operation.childPeerId, {
|
||||
type: "route-status",
|
||||
revision: operation.baseRevision,
|
||||
@@ -1152,10 +761,7 @@ export class HybridMediaRouter {
|
||||
}
|
||||
this.resourceWaiters.delete(roomId);
|
||||
let currentOperation = controller.snapshot().operation;
|
||||
if (
|
||||
operation.owner.kind === "route" &&
|
||||
plan.endpointTransition.kind === "bounded-gap"
|
||||
) {
|
||||
if (plan.endpointTransition.kind === "bounded-gap") {
|
||||
const beforeGap = controller.snapshot().revision;
|
||||
const gap = controller.retireCurrentCandidateProducer(guard, this.now());
|
||||
this.releaseResources(gap.released);
|
||||
@@ -1190,13 +796,6 @@ export class HybridMediaRouter {
|
||||
});
|
||||
this.releaseResources(begun.released);
|
||||
if (!begun.accepted || !begun.operation?.current) {
|
||||
if (operation.owner.kind === "codec") {
|
||||
this.abortCodecSfuReplacement(
|
||||
roomId,
|
||||
operation.owner.codecGeneration,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (begun.exhausted) {
|
||||
this.sendViewerRouteStatus(roomId, operation.childPeerId, {
|
||||
type: "route-status",
|
||||
@@ -1213,15 +812,7 @@ export class HybridMediaRouter {
|
||||
begun.operation,
|
||||
preparation.prepared,
|
||||
);
|
||||
if (begun.operation.owner.kind === "codec") {
|
||||
this.clearDeadline(room);
|
||||
this.settleCodecPreparation(
|
||||
room,
|
||||
this.codecRouteSnapshot(roomId),
|
||||
);
|
||||
} else {
|
||||
this.scheduleDeadline(roomId, room, begun.operation);
|
||||
}
|
||||
this.scheduleDeadline(roomId, room, begun.operation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1239,10 +830,7 @@ export class HybridMediaRouter {
|
||||
if (!child || child.sessionId !== operation.childSessionId || !shareGeneration) {
|
||||
return { kind: "denied", rejectionBucket: "stale" };
|
||||
}
|
||||
const connectionId =
|
||||
operation.owner.kind === "codec"
|
||||
? operation.owner.connectionId
|
||||
: opaqueId();
|
||||
const connectionId = opaqueId();
|
||||
const overlap =
|
||||
plan.endpointTransition.kind === "overlap"
|
||||
? overlapResource(plan.endpointTransition.producerPeerId)
|
||||
@@ -1262,9 +850,7 @@ export class HybridMediaRouter {
|
||||
if (
|
||||
!fallback ||
|
||||
!host ||
|
||||
host.peerId !== room.hostPeerId ||
|
||||
(operation.owner.kind === "codec" &&
|
||||
host.sessionId !== operation.owner.hostSessionId)
|
||||
host.peerId !== room.hostPeerId
|
||||
) {
|
||||
return { kind: "denied", rejectionBucket: "stale" };
|
||||
}
|
||||
@@ -1333,13 +919,6 @@ export class HybridMediaRouter {
|
||||
shareGeneration,
|
||||
host,
|
||||
overlap,
|
||||
operation.owner.kind === "codec"
|
||||
? {
|
||||
publicationGeneration: operation.owner.publicationGeneration,
|
||||
publicationConnectionId:
|
||||
operation.owner.publicationConnectionId,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1351,14 +930,9 @@ export class HybridMediaRouter {
|
||||
shareGeneration: string,
|
||||
host: { peerId: string; sessionId: string },
|
||||
overlap?: OverlapRouteResource,
|
||||
codecIdentity?: {
|
||||
publicationGeneration: string;
|
||||
publicationConnectionId: string;
|
||||
},
|
||||
): Promise<PrepareResult> {
|
||||
const fallback = this.options.sfuFallback!;
|
||||
const publicationGeneration =
|
||||
codecIdentity?.publicationGeneration ?? opaqueId();
|
||||
const publicationGeneration = opaqueId();
|
||||
const publicationFence: SfuResourceFence = {
|
||||
roomId,
|
||||
shareGeneration,
|
||||
@@ -1377,8 +951,7 @@ export class HybridMediaRouter {
|
||||
return { kind: "denied", rejectionBucket: "sfu-admission" };
|
||||
}
|
||||
const subscription = subscriptionResource(subscriptionFence);
|
||||
const publicationConnectionId =
|
||||
codecIdentity?.publicationConnectionId ?? publicationGeneration;
|
||||
const publicationConnectionId = publicationGeneration;
|
||||
const publication = publicationResource(publicationFence);
|
||||
try {
|
||||
await fallback.roomControl.createRoom(publicationFence);
|
||||
@@ -1475,9 +1048,7 @@ export class HybridMediaRouter {
|
||||
if (
|
||||
prepared.hostSfuConfig &&
|
||||
host &&
|
||||
host.sessionId === prepared.hostSessionId &&
|
||||
(operation.owner.kind !== "codec" ||
|
||||
host.sessionId === operation.owner.hostSessionId)
|
||||
host.sessionId === prepared.hostSessionId
|
||||
) {
|
||||
this.options.sendToSession(host.sessionId, {
|
||||
type: "route-update",
|
||||
@@ -1723,12 +1294,6 @@ export class HybridMediaRouter {
|
||||
return;
|
||||
}
|
||||
if (this.options.sfuFallback?.admission.beginDrain(resource.fence)) {
|
||||
const room = this.rooms.get(resource.fence.roomId);
|
||||
if (room?.codecOperation) {
|
||||
room.codecOperation.drainBeforePreparationKey = managedSfuRoomName(
|
||||
resource.fence,
|
||||
);
|
||||
}
|
||||
this.scheduleSfuDrain(resource.fence);
|
||||
}
|
||||
}
|
||||
@@ -1744,18 +1309,9 @@ export class HybridMediaRouter {
|
||||
return accepted;
|
||||
}
|
||||
|
||||
private wakeResourceWaiters(completedDrainKey?: string): void {
|
||||
const roomIds: string[] = [];
|
||||
for (const [roomId, waiter] of this.resourceWaiters) {
|
||||
if (
|
||||
waiter.kind === "drain" &&
|
||||
waiter.drainKey !== completedDrainKey
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.resourceWaiters.delete(roomId);
|
||||
roomIds.push(roomId);
|
||||
}
|
||||
private wakeResourceWaiters(): void {
|
||||
const roomIds = [...this.resourceWaiters];
|
||||
this.resourceWaiters.clear();
|
||||
for (const roomId of roomIds) {
|
||||
const controller = this.rooms.get(roomId)?.controller;
|
||||
if (!controller) continue;
|
||||
@@ -1936,11 +1492,7 @@ export class HybridMediaRouter {
|
||||
throw new Error("LiveKit drain has no matching resource generation");
|
||||
}
|
||||
this.sfuDrainTasks.delete(key);
|
||||
const room = this.rooms.get(task.fence.roomId);
|
||||
if (room?.codecDrainBarrierKey === key) {
|
||||
room.codecDrainBarrierKey = undefined;
|
||||
}
|
||||
this.wakeResourceWaiters(key);
|
||||
this.wakeResourceWaiters();
|
||||
} catch (error) {
|
||||
if (this.sfuDrainTasks.get(key) !== task) return;
|
||||
if (this.closing) throw error;
|
||||
@@ -1957,30 +1509,11 @@ export class HybridMediaRouter {
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private settleCodecPreparation(
|
||||
room: RoomRuntime,
|
||||
snapshot: CodecRouteSnapshot | null,
|
||||
): void {
|
||||
const resolve = room.codecOperation?.resolvePreparation;
|
||||
if (!resolve) return;
|
||||
room.codecOperation!.resolvePreparation = undefined;
|
||||
resolve(snapshot);
|
||||
}
|
||||
|
||||
private carryCodecDrainBarrier(room: RoomRuntime): void {
|
||||
const key = room.codecOperation?.drainBeforePreparationKey;
|
||||
if (key && this.sfuDrainTasks.has(key)) {
|
||||
room.codecDrainBarrierKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
private clearRoom(roomId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return;
|
||||
this.clearDeadline(room);
|
||||
this.cancelHostOfflineCheck(roomId);
|
||||
this.settleCodecPreparation(room, null);
|
||||
room.codecOperation = undefined;
|
||||
const controller = room.controller;
|
||||
room.controller = undefined;
|
||||
room.requested = false;
|
||||
@@ -2012,7 +1545,6 @@ function cursorGuard(
|
||||
plan: CandidatePlan,
|
||||
): CandidateCursorGuard {
|
||||
return {
|
||||
owner: { ...operation.owner },
|
||||
childPeerId: operation.childPeerId,
|
||||
childSessionId: operation.childSessionId,
|
||||
baseRevision: operation.baseRevision,
|
||||
@@ -2047,13 +1579,6 @@ function preparedRouteCandidate(
|
||||
childPeerId: operation.childPeerId,
|
||||
connectionId,
|
||||
transport: tuple.kind === "peer" ? tuple.transport : "sfu",
|
||||
codecTransition:
|
||||
operation.owner.kind === "codec"
|
||||
? {
|
||||
generation: operation.owner.codecGeneration,
|
||||
videoCodec: operation.owner.videoCodec,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
type CodecTransitionGeneration,
|
||||
type RouteDemandReason,
|
||||
type RouteDiagnosticFinalRoute,
|
||||
type RouteDiagnosticRejectionBucket,
|
||||
type RouteDiagnosticSnapshot,
|
||||
type VideoCodecPreference,
|
||||
} from "../shared/protocol.js";
|
||||
import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js";
|
||||
|
||||
@@ -76,7 +74,6 @@ export interface CandidateGuard {
|
||||
}
|
||||
|
||||
export interface CandidateCursorGuard {
|
||||
owner: OperationOwner;
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
baseRevision: number;
|
||||
@@ -115,23 +112,7 @@ interface Attempt<Resource> {
|
||||
reservation: CandidateReservation<Resource>;
|
||||
}
|
||||
|
||||
export type CodecOperationPhase = "preparing" | "prepared" | "proving";
|
||||
|
||||
export type OperationOwner =
|
||||
| { kind: "route" }
|
||||
| {
|
||||
kind: "codec";
|
||||
codecGeneration: CodecTransitionGeneration;
|
||||
phase: CodecOperationPhase;
|
||||
videoCodec: VideoCodecPreference;
|
||||
hostSessionId: string;
|
||||
publicationGeneration: string;
|
||||
connectionId: string;
|
||||
publicationConnectionId: string;
|
||||
};
|
||||
|
||||
interface ChildOperation<Resource> {
|
||||
owner: OperationOwner;
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
reason: RouteDemandReason;
|
||||
@@ -140,12 +121,10 @@ interface ChildOperation<Resource> {
|
||||
cursor: number;
|
||||
deadlineAtMs: number;
|
||||
builtAtFactVersion: number;
|
||||
deferredRetirements: EdgeGuard[];
|
||||
current?: Attempt<Resource>;
|
||||
}
|
||||
|
||||
export interface OperationSnapshot {
|
||||
owner: OperationOwner;
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
reason: RouteDemandReason;
|
||||
@@ -188,64 +167,6 @@ export interface BeginResult<Resource> {
|
||||
released: readonly Resource[];
|
||||
}
|
||||
|
||||
export interface RetireTransportsResult<Resource> {
|
||||
accepted: boolean;
|
||||
activeRevision: number;
|
||||
retiredPeerIds: readonly string[];
|
||||
released: readonly Resource[];
|
||||
}
|
||||
|
||||
export interface CodecOperationGuard {
|
||||
codecGeneration: CodecTransitionGeneration;
|
||||
hostSessionId: string;
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
publicationGeneration: string;
|
||||
connectionId: string;
|
||||
publicationConnectionId: string;
|
||||
}
|
||||
|
||||
export interface BeginCodecSfuPublicationInput {
|
||||
routeRevision: number;
|
||||
hostSessionId: string;
|
||||
codecGeneration: CodecTransitionGeneration;
|
||||
videoCodec: VideoCodecPreference;
|
||||
publicationGeneration: string;
|
||||
connectionId: string;
|
||||
publicationConnectionId: string;
|
||||
nowMs: number;
|
||||
deadlineAtMs: number;
|
||||
retainedAnchor?: {
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BeginCodecSfuPublicationResult<Resource> {
|
||||
accepted: boolean;
|
||||
activeRevision: number;
|
||||
anchorPeerId?: string;
|
||||
guard?: CodecOperationGuard;
|
||||
retiredPeerIds: readonly string[];
|
||||
operation?: OperationSnapshot;
|
||||
released: readonly Resource[];
|
||||
}
|
||||
|
||||
export interface CodecOperationMutationResult<Resource> {
|
||||
accepted: boolean;
|
||||
activeRevision: number;
|
||||
operation?: OperationSnapshot;
|
||||
released: readonly Resource[];
|
||||
}
|
||||
|
||||
export interface TransitionCodecOperationInput {
|
||||
guard: CodecOperationGuard;
|
||||
expectedPhase: CodecOperationPhase;
|
||||
phase: CodecOperationPhase;
|
||||
nowMs?: number;
|
||||
deadlineAtMs?: number;
|
||||
}
|
||||
|
||||
interface HostPublication<Resource> {
|
||||
generation: string;
|
||||
hostSessionId: string;
|
||||
@@ -518,242 +439,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
this.assertGraph();
|
||||
}
|
||||
|
||||
beginCodecSfuPublication(
|
||||
input: BeginCodecSfuPublicationInput,
|
||||
): BeginCodecSfuPublicationResult<Resource> {
|
||||
assertCodecOperationInput(input);
|
||||
const rejected = (): BeginCodecSfuPublicationResult<Resource> => ({
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
retiredPeerIds: [],
|
||||
released: [],
|
||||
});
|
||||
const host = this.participants.get(this.options.hostPeerId);
|
||||
if (
|
||||
!this.options.sfuEnabled ||
|
||||
!this.paused ||
|
||||
this.operation ||
|
||||
this.revision !== input.routeRevision ||
|
||||
host?.sessionId !== input.hostSessionId ||
|
||||
host.departureConfirmed
|
||||
) {
|
||||
return rejected();
|
||||
}
|
||||
|
||||
const publication = this.hostPublication;
|
||||
if (
|
||||
publication &&
|
||||
(publication.hostSessionId !== input.hostSessionId ||
|
||||
publication.generation === input.publicationGeneration ||
|
||||
publication.connectionId === input.publicationConnectionId)
|
||||
) {
|
||||
return rejected();
|
||||
}
|
||||
const publicationViewers = publication
|
||||
? [...this.upstreamByViewer]
|
||||
.filter(
|
||||
([, edge]) =>
|
||||
edge.kind === "sfu" &&
|
||||
edge.publicationGeneration === publication.generation,
|
||||
)
|
||||
.map(([peerId]) => this.participants.get(peerId))
|
||||
.filter((participant): participant is Participant =>
|
||||
Boolean(participant?.sessionId && !participant.departureConfirmed),
|
||||
)
|
||||
.sort(compareParticipant)
|
||||
: [];
|
||||
let anchor: Participant | undefined;
|
||||
if (input.retainedAnchor) {
|
||||
const retained = this.participants.get(input.retainedAnchor.childPeerId);
|
||||
const retainedEdge = this.upstreamByViewer.get(
|
||||
input.retainedAnchor.childPeerId,
|
||||
);
|
||||
if (
|
||||
retained?.role !== "viewer" ||
|
||||
retained.sessionId !== input.retainedAnchor.childSessionId ||
|
||||
retained.departureConfirmed ||
|
||||
(publication &&
|
||||
(retainedEdge?.kind !== "sfu" ||
|
||||
retainedEdge.publicationGeneration !== publication.generation ||
|
||||
(publication.physicalActive &&
|
||||
(!retainedEdge.physicalActive || !retainedEdge.usable)))) ||
|
||||
(!publication && retainedEdge)
|
||||
) {
|
||||
return rejected();
|
||||
}
|
||||
anchor = retained;
|
||||
} else {
|
||||
anchor = publication?.physicalActive
|
||||
? publicationViewers.find((participant) => {
|
||||
const edge = this.upstreamByViewer.get(participant.peerId);
|
||||
return edge?.physicalActive && edge.usable;
|
||||
})
|
||||
: publicationViewers[0];
|
||||
}
|
||||
if (!anchor?.sessionId) return rejected();
|
||||
const anchorEdge = this.upstreamByViewer.get(anchor.peerId);
|
||||
if (anchorEdge?.connectionId === input.connectionId) return rejected();
|
||||
const copiesAfterRetirement =
|
||||
this.physicalCopies(this.options.hostPeerId) -
|
||||
(publication?.physicalActive ? 1 : 0);
|
||||
if (copiesAfterRetirement + 1 > host.effectiveDownstreamCapacity) {
|
||||
return rejected();
|
||||
}
|
||||
|
||||
const released = new Set<Resource>();
|
||||
const retiredPeerIds: string[] = [];
|
||||
if (publication) {
|
||||
for (const [viewerPeerId, edge] of this.upstreamByViewer) {
|
||||
if (
|
||||
edge.kind !== "sfu" ||
|
||||
edge.publicationGeneration !== publication.generation
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (edge.physicalActive) {
|
||||
released.add(edge.resource);
|
||||
retiredPeerIds.push(viewerPeerId);
|
||||
}
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
const participant = this.participants.get(viewerPeerId);
|
||||
if (participant) {
|
||||
participant.blockedAtFactVersion = undefined;
|
||||
participant.failedTuple = undefined;
|
||||
}
|
||||
this.recordDemand(viewerPeerId, input.nowMs, "edge-unavailable");
|
||||
}
|
||||
if (publication.physicalActive) released.add(publication.resource);
|
||||
publication.physicalActive = false;
|
||||
publication.usable = false;
|
||||
this.revision = this.allocateRevision();
|
||||
this.touchFacts();
|
||||
}
|
||||
|
||||
const tuple: CandidateTuple = {
|
||||
kind: "sfu",
|
||||
publication: publication ? "replace" : "create",
|
||||
};
|
||||
const plan = this.planCandidate(anchor.peerId, tuple);
|
||||
if (!plan) {
|
||||
throw new Error("Codec SFU publication candidate is not admissible");
|
||||
}
|
||||
this.ensureDemand(anchor.peerId, input.nowMs, "edge-unavailable");
|
||||
this.startOperationTiming(anchor.peerId, input.nowMs);
|
||||
this.operation = {
|
||||
owner: {
|
||||
kind: "codec",
|
||||
codecGeneration: input.codecGeneration,
|
||||
phase: "preparing",
|
||||
videoCodec: input.videoCodec,
|
||||
hostSessionId: input.hostSessionId,
|
||||
publicationGeneration: input.publicationGeneration,
|
||||
connectionId: input.connectionId,
|
||||
publicationConnectionId: input.publicationConnectionId,
|
||||
},
|
||||
childPeerId: anchor.peerId,
|
||||
childSessionId: anchor.sessionId,
|
||||
reason: "edge-unavailable",
|
||||
baseRevision: this.revision,
|
||||
candidates: [plan],
|
||||
cursor: 0,
|
||||
deadlineAtMs: input.deadlineAtMs,
|
||||
builtAtFactVersion: this.factVersion,
|
||||
deferredRetirements: [],
|
||||
};
|
||||
this.assertGraph();
|
||||
const guard = codecOperationGuard(this.operation);
|
||||
return {
|
||||
accepted: true,
|
||||
activeRevision: this.revision,
|
||||
anchorPeerId: anchor.peerId,
|
||||
guard,
|
||||
retiredPeerIds,
|
||||
operation: this.operationSnapshot(),
|
||||
released: [...released],
|
||||
};
|
||||
}
|
||||
|
||||
transitionCodecOperation(
|
||||
input: TransitionCodecOperationInput,
|
||||
): CodecOperationMutationResult<Resource> {
|
||||
const operation = this.operation;
|
||||
if (
|
||||
!operation ||
|
||||
!codecOperationGuardMatches(input.guard, operation) ||
|
||||
operation.owner.kind !== "codec" ||
|
||||
!operation.current ||
|
||||
operation.owner.phase !== input.expectedPhase ||
|
||||
!codecPhaseTransitionAllowed(input.expectedPhase, input.phase)
|
||||
) {
|
||||
return {
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
operation: this.operationSnapshot(),
|
||||
released: [],
|
||||
};
|
||||
}
|
||||
if (input.phase === "proving") {
|
||||
if (
|
||||
input.nowMs === undefined ||
|
||||
input.deadlineAtMs === undefined ||
|
||||
!Number.isSafeInteger(input.deadlineAtMs) ||
|
||||
input.deadlineAtMs <= input.nowMs
|
||||
) {
|
||||
throw new Error("Codec proof deadline must be in the future");
|
||||
}
|
||||
operation.deadlineAtMs = input.deadlineAtMs;
|
||||
}
|
||||
operation.owner.phase = input.phase;
|
||||
return {
|
||||
accepted: true,
|
||||
activeRevision: this.revision,
|
||||
operation: this.operationSnapshot(),
|
||||
released: [],
|
||||
};
|
||||
}
|
||||
|
||||
abortCodecOperation(
|
||||
guard: CodecOperationGuard,
|
||||
nowMs?: number,
|
||||
): CodecOperationMutationResult<Resource> {
|
||||
const operation = this.operation;
|
||||
if (!operation || !codecOperationGuardMatches(guard, operation)) {
|
||||
return {
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
operation: this.operationSnapshot(),
|
||||
released: [],
|
||||
};
|
||||
}
|
||||
const released = operation.current
|
||||
? [...reservationResources(operation.current.reservation)]
|
||||
: [];
|
||||
if (nowMs !== undefined) {
|
||||
this.finishTiming(
|
||||
operation.childPeerId,
|
||||
nowMs,
|
||||
this.currentFinalRoute(operation.childPeerId),
|
||||
"aborted",
|
||||
);
|
||||
}
|
||||
let revisionAdvanced = Boolean(operation.current);
|
||||
if (operation.current) this.advanceActiveRevision(operation);
|
||||
if (this.retireDeferredCodecEdges(operation, released)) {
|
||||
if (!revisionAdvanced) {
|
||||
this.advanceActiveRevision(operation);
|
||||
revisionAdvanced = true;
|
||||
}
|
||||
this.touchFacts();
|
||||
}
|
||||
this.operation = undefined;
|
||||
return {
|
||||
accepted: true,
|
||||
activeRevision: this.revision,
|
||||
released,
|
||||
};
|
||||
}
|
||||
|
||||
invalidateEdge(guard: EdgeGuard, nowMs?: number): boolean {
|
||||
const child = this.participants.get(guard.childPeerId);
|
||||
const edge = this.upstreamByViewer.get(guard.childPeerId);
|
||||
@@ -849,92 +534,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
return edge.transport === "direct" ? [] : [edge.resource];
|
||||
}
|
||||
|
||||
retireCommittedTransportsForRouteWait(
|
||||
guards: readonly EdgeGuard[],
|
||||
nowMs: number,
|
||||
): RetireTransportsResult<Resource> {
|
||||
const childPeerIds = new Set<string>();
|
||||
const owned: Array<{
|
||||
childPeerId: string;
|
||||
child: Participant;
|
||||
edge: CommittedEdge<Resource>;
|
||||
}> = [];
|
||||
const codecOperation =
|
||||
this.operation?.owner.kind === "codec" ? this.operation : undefined;
|
||||
if (
|
||||
!this.paused ||
|
||||
(this.operation && !codecOperation) ||
|
||||
guards.length === 0
|
||||
) {
|
||||
return {
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
retiredPeerIds: [],
|
||||
released: [],
|
||||
};
|
||||
}
|
||||
for (const guard of guards) {
|
||||
const child = this.participants.get(guard.childPeerId);
|
||||
const edge = this.upstreamByViewer.get(guard.childPeerId);
|
||||
if (
|
||||
childPeerIds.has(guard.childPeerId) ||
|
||||
guard.routeRevision !== this.revision ||
|
||||
!child ||
|
||||
child.sessionId !== guard.childSessionId ||
|
||||
!edge ||
|
||||
!edge.physicalActive ||
|
||||
edge.childSessionId !== guard.childSessionId ||
|
||||
edge.connectionId !== guard.connectionId ||
|
||||
(edge.kind === "peer" &&
|
||||
edge.parentSessionId !== guard.parentSessionId)
|
||||
) {
|
||||
return {
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
retiredPeerIds: [],
|
||||
released: [],
|
||||
};
|
||||
}
|
||||
childPeerIds.add(guard.childPeerId);
|
||||
owned.push({ childPeerId: guard.childPeerId, child, edge });
|
||||
}
|
||||
|
||||
const released: Resource[] = [];
|
||||
let invalidated = false;
|
||||
for (const { childPeerId, child, edge } of owned) {
|
||||
if (edge.usable) {
|
||||
edge.usable = false;
|
||||
this.recordDemand(childPeerId, nowMs, "edge-unavailable");
|
||||
invalidated = true;
|
||||
}
|
||||
child.blockedAtFactVersion = undefined;
|
||||
child.failedTuple = undefined;
|
||||
}
|
||||
if (codecOperation) {
|
||||
const deferred = guards.filter(
|
||||
(guard) =>
|
||||
!codecOperation.deferredRetirements.some((candidate) =>
|
||||
edgeGuardEquals(candidate, guard),
|
||||
),
|
||||
);
|
||||
codecOperation.deferredRetirements.push(
|
||||
...deferred.map((guard) => ({ ...guard })),
|
||||
);
|
||||
if (invalidated) this.touchFacts();
|
||||
codecOperation.builtAtFactVersion = this.factVersion;
|
||||
} else if (invalidated) {
|
||||
this.revision = this.allocateRevision();
|
||||
this.touchFacts();
|
||||
}
|
||||
this.assertGraph();
|
||||
return {
|
||||
accepted: true,
|
||||
activeRevision: this.revision,
|
||||
retiredPeerIds: [...childPeerIds],
|
||||
released,
|
||||
};
|
||||
}
|
||||
|
||||
retireHostPublication(guard: {
|
||||
hostSessionId: string;
|
||||
routeRevision: number;
|
||||
@@ -1009,7 +608,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
this.startOperationTiming(childPeerId, nowMs);
|
||||
this.operation = {
|
||||
owner: { kind: "route" },
|
||||
childPeerId,
|
||||
childSessionId: child.sessionId!,
|
||||
reason,
|
||||
@@ -1018,7 +616,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
cursor: 0,
|
||||
deadlineAtMs: nowMs + this.options.operationTimeoutMs,
|
||||
builtAtFactVersion: this.factVersion,
|
||||
deferredRetirements: [],
|
||||
};
|
||||
return {
|
||||
operation: this.operationSnapshot(),
|
||||
@@ -1057,27 +654,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
exhausted: validation.exhausted,
|
||||
};
|
||||
}
|
||||
if (
|
||||
operation.owner.kind === "codec" &&
|
||||
(operation.owner.phase !== "preparing" ||
|
||||
plan.tuple.kind !== "sfu" ||
|
||||
this.participants.get(this.options.hostPeerId)?.sessionId !==
|
||||
operation.owner.hostSessionId ||
|
||||
input.connectionId !== operation.owner.connectionId ||
|
||||
input.publicationGeneration !==
|
||||
operation.owner.publicationGeneration ||
|
||||
input.publicationConnectionId !==
|
||||
operation.owner.publicationConnectionId)
|
||||
) {
|
||||
return {
|
||||
accepted: false,
|
||||
operation: this.operationSnapshot(),
|
||||
released: [
|
||||
...validation.released,
|
||||
...reservationResources(input.reservation),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (plan.endpointTransition.kind === "bounded-gap") {
|
||||
return {
|
||||
accepted: false,
|
||||
@@ -1238,16 +814,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const validation = this.validateOrAdvance(nowMs);
|
||||
const operation = this.operation;
|
||||
const attempt = operation?.current;
|
||||
if (
|
||||
operation?.owner.kind === "codec" &&
|
||||
operation.owner.phase !== "proving"
|
||||
) {
|
||||
return {
|
||||
accepted: false,
|
||||
activeRevision: this.revision,
|
||||
released: validation.released,
|
||||
};
|
||||
}
|
||||
if (!operation || !attempt || !this.guardMatches(guard, operation, attempt)) {
|
||||
return { accepted: false, exhausted: validation.exhausted, activeRevision: this.revision, released: validation.released };
|
||||
}
|
||||
@@ -1339,13 +905,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
} = { released, expired: false, consumedGuard: false };
|
||||
let operation = this.operation;
|
||||
if (!operation) return result;
|
||||
if (
|
||||
!(
|
||||
operation.owner.kind === "codec" &&
|
||||
operation.owner.phase === "prepared"
|
||||
) &&
|
||||
nowMs >= operation.deadlineAtMs
|
||||
) {
|
||||
if (nowMs >= operation.deadlineAtMs) {
|
||||
if (operation.current) released.push(...reservationResources(operation.current.reservation));
|
||||
const revisionAdvanced = Boolean(operation.current);
|
||||
if (operation.current) this.advanceActiveRevision(operation);
|
||||
@@ -1376,12 +936,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
if (this.participants.get(operation.childPeerId)?.sessionId !== operation.childSessionId) {
|
||||
if (operation.current) released.push(...reservationResources(operation.current.reservation));
|
||||
const revisionAdvanced = Boolean(operation.current);
|
||||
if (operation.current) this.advanceActiveRevision(operation);
|
||||
if (this.retireDeferredCodecEdges(operation, released)) {
|
||||
if (!revisionAdvanced) this.advanceActiveRevision(operation);
|
||||
this.touchFacts();
|
||||
}
|
||||
this.finishTiming(
|
||||
operation.childPeerId,
|
||||
nowMs,
|
||||
@@ -1448,17 +1003,9 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private commitAttempt(operation: ChildOperation<Resource>, attempt: Attempt<Resource>): Resource[] {
|
||||
const beforeResources = new Set(this.committedResources());
|
||||
const old = this.upstreamByViewer.get(operation.childPeerId);
|
||||
const codecReplacementGeneration =
|
||||
operation.owner.kind === "codec" &&
|
||||
attempt.tuple.kind === "sfu" &&
|
||||
attempt.tuple.publication === "replace"
|
||||
? this.hostPublication?.generation
|
||||
: undefined;
|
||||
|
||||
if (attempt.tuple.kind === "sfu" && attempt.tuple.publication === "replace") {
|
||||
if (operation.owner.kind === "route") {
|
||||
this.removePublicationGeneration(this.hostPublication!.generation);
|
||||
}
|
||||
this.removePublicationGeneration(this.hostPublication!.generation);
|
||||
}
|
||||
|
||||
if (attempt.tuple.kind === "peer") {
|
||||
@@ -1489,20 +1036,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
resource: reservation.publication,
|
||||
};
|
||||
}
|
||||
if (codecReplacementGeneration) {
|
||||
for (const [viewerPeerId, existing] of this.upstreamByViewer) {
|
||||
if (
|
||||
viewerPeerId === operation.childPeerId ||
|
||||
existing.kind !== "sfu" ||
|
||||
existing.publicationGeneration !== codecReplacementGeneration
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
existing.publicationGeneration = generation;
|
||||
existing.usable = false;
|
||||
existing.physicalActive = false;
|
||||
}
|
||||
}
|
||||
const edge = (attempt.reservation as Extract<CandidateReservation<Resource>, { kind: "sfu-reuse" | "sfu-create" }>).edge;
|
||||
this.upstreamByViewer.set(operation.childPeerId, {
|
||||
kind: "sfu", childSessionId: operation.childSessionId,
|
||||
@@ -1511,7 +1044,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
});
|
||||
}
|
||||
this.revision = attempt.revision;
|
||||
this.retireDeferredCodecEdges(operation);
|
||||
this.operation = undefined;
|
||||
this.touchFacts();
|
||||
const participant = this.participants.get(operation.childPeerId);
|
||||
@@ -1767,12 +1299,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const released = operation.current
|
||||
? [...reservationResources(operation.current.reservation)]
|
||||
: [];
|
||||
const revisionAdvanced = Boolean(operation.current);
|
||||
if (operation.current) this.advanceActiveRevision(operation);
|
||||
if (this.retireDeferredCodecEdges(operation, released)) {
|
||||
if (!revisionAdvanced) this.advanceActiveRevision(operation);
|
||||
this.touchFacts();
|
||||
}
|
||||
this.operation = undefined;
|
||||
return released;
|
||||
}
|
||||
@@ -1789,53 +1316,20 @@ export class RoomRouteController<Resource = unknown> {
|
||||
revisionAdvanced = false,
|
||||
): void {
|
||||
const child = this.participants.get(operation.childPeerId);
|
||||
const retiredDeferred = this.retireDeferredCodecEdges(operation, released);
|
||||
this.operation = undefined;
|
||||
const retiredInvalid = block && child
|
||||
? this.retireInvalidOperationEdge(operation.childPeerId, released)
|
||||
: false;
|
||||
if ((retiredDeferred || retiredInvalid) && !revisionAdvanced) {
|
||||
if (retiredInvalid && !revisionAdvanced) {
|
||||
this.revision = this.allocateRevision();
|
||||
}
|
||||
if (retiredDeferred || retiredInvalid) {
|
||||
if (retiredInvalid) {
|
||||
this.touchFacts();
|
||||
}
|
||||
if (!block || !child) return;
|
||||
child.blockedAtFactVersion = this.factVersion;
|
||||
}
|
||||
|
||||
private retireDeferredCodecEdges(
|
||||
operation: ChildOperation<Resource>,
|
||||
released: Resource[] = [],
|
||||
): boolean {
|
||||
if (
|
||||
operation.owner.kind !== "codec" ||
|
||||
operation.deferredRetirements.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let retired = false;
|
||||
for (const guard of operation.deferredRetirements) {
|
||||
const edge = this.upstreamByViewer.get(guard.childPeerId);
|
||||
if (
|
||||
!edge ||
|
||||
!edge.physicalActive ||
|
||||
edge.childSessionId !== guard.childSessionId ||
|
||||
edge.connectionId !== guard.connectionId ||
|
||||
(edge.kind === "peer" &&
|
||||
edge.parentSessionId !== guard.parentSessionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
if (edge.kind === "sfu") released.push(edge.resource);
|
||||
retired = true;
|
||||
}
|
||||
operation.deferredRetirements = [];
|
||||
return retired;
|
||||
}
|
||||
|
||||
private retireInvalidOperationEdge(childPeerId: string, released: Resource[]): boolean {
|
||||
const edge = this.upstreamByViewer.get(childPeerId);
|
||||
if (!edge || !this.edgeRequiresMove(childPeerId, edge)) return false;
|
||||
@@ -1887,8 +1381,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private operationSnapshot(): OperationSnapshot | undefined {
|
||||
const operation = this.operation;
|
||||
if (!operation) return undefined;
|
||||
return { owner: { ...operation.owner },
|
||||
childPeerId: operation.childPeerId, childSessionId: operation.childSessionId,
|
||||
return { childPeerId: operation.childPeerId, childSessionId: operation.childSessionId,
|
||||
reason: operation.reason,
|
||||
baseRevision: operation.baseRevision, factVersion: operation.builtAtFactVersion,
|
||||
candidates: operation.candidates.map(cloneCandidatePlan),
|
||||
@@ -1903,12 +1396,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
nowMs: number,
|
||||
released: Resource[],
|
||||
): boolean {
|
||||
if (
|
||||
operation.owner.kind === "codec" &&
|
||||
operation.owner.phase === "prepared"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const plan = operation.current
|
||||
? { tuple: operation.current.tuple }
|
||||
: operation.candidates[operation.cursor];
|
||||
@@ -2008,7 +1495,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
|
||||
private cursorGuardMatches(guard: CandidateCursorGuard, operation: ChildOperation<Resource>): boolean {
|
||||
const plan = operation.candidates[operation.cursor];
|
||||
return Boolean(plan && operationOwnerEquals(guard.owner, operation.owner) &&
|
||||
return Boolean(plan &&
|
||||
guard.childPeerId === operation.childPeerId &&
|
||||
guard.childSessionId === operation.childSessionId && guard.baseRevision === operation.baseRevision &&
|
||||
guard.factVersion === operation.builtAtFactVersion && guard.cursor === operation.cursor &&
|
||||
@@ -2326,102 +1813,6 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function codecOperationGuard<Resource>(
|
||||
operation: ChildOperation<Resource>,
|
||||
): CodecOperationGuard {
|
||||
if (operation.owner.kind !== "codec") {
|
||||
throw new Error("Route operation is not codec-owned");
|
||||
}
|
||||
return {
|
||||
codecGeneration: operation.owner.codecGeneration,
|
||||
hostSessionId: operation.owner.hostSessionId,
|
||||
childPeerId: operation.childPeerId,
|
||||
childSessionId: operation.childSessionId,
|
||||
publicationGeneration: operation.owner.publicationGeneration,
|
||||
connectionId: operation.owner.connectionId,
|
||||
publicationConnectionId: operation.owner.publicationConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
function codecOperationGuardMatches<Resource>(
|
||||
guard: CodecOperationGuard,
|
||||
operation: ChildOperation<Resource>,
|
||||
): boolean {
|
||||
return (
|
||||
operation.owner.kind === "codec" &&
|
||||
operation.owner.codecGeneration === guard.codecGeneration &&
|
||||
operation.owner.hostSessionId === guard.hostSessionId &&
|
||||
operation.childPeerId === guard.childPeerId &&
|
||||
operation.childSessionId === guard.childSessionId &&
|
||||
operation.owner.publicationGeneration ===
|
||||
guard.publicationGeneration &&
|
||||
operation.owner.connectionId === guard.connectionId &&
|
||||
operation.owner.publicationConnectionId ===
|
||||
guard.publicationConnectionId
|
||||
);
|
||||
}
|
||||
|
||||
function operationOwnerEquals(left: OperationOwner, right: OperationOwner): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === "route" || right.kind === "route") return true;
|
||||
return (
|
||||
left.codecGeneration === right.codecGeneration &&
|
||||
left.phase === right.phase &&
|
||||
left.videoCodec === right.videoCodec &&
|
||||
left.hostSessionId === right.hostSessionId &&
|
||||
left.publicationGeneration === right.publicationGeneration &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.publicationConnectionId === right.publicationConnectionId
|
||||
);
|
||||
}
|
||||
|
||||
function edgeGuardEquals(left: EdgeGuard, right: EdgeGuard): boolean {
|
||||
return (
|
||||
left.childPeerId === right.childPeerId &&
|
||||
left.childSessionId === right.childSessionId &&
|
||||
left.parentSessionId === right.parentSessionId &&
|
||||
left.routeRevision === right.routeRevision &&
|
||||
left.connectionId === right.connectionId
|
||||
);
|
||||
}
|
||||
|
||||
function codecPhaseTransitionAllowed(
|
||||
from: CodecOperationPhase,
|
||||
to: CodecOperationPhase,
|
||||
): boolean {
|
||||
return (
|
||||
(from === "preparing" && to === "prepared") ||
|
||||
(from === "prepared" && to === "proving") ||
|
||||
(from === "proving" && to === "prepared")
|
||||
);
|
||||
}
|
||||
|
||||
function assertCodecOperationInput(input: BeginCodecSfuPublicationInput): void {
|
||||
if (!Number.isSafeInteger(input.codecGeneration) || input.codecGeneration <= 0) {
|
||||
throw new Error("Codec generation must be a positive safe integer");
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(input.nowMs) ||
|
||||
!Number.isSafeInteger(input.deadlineAtMs) ||
|
||||
input.deadlineAtMs <= input.nowMs
|
||||
) {
|
||||
throw new Error("Codec preparation deadline must be in the future");
|
||||
}
|
||||
if (
|
||||
![input.publicationGeneration, input.connectionId, input.publicationConnectionId]
|
||||
.every((value) => value.length > 0)
|
||||
) {
|
||||
throw new Error("Codec SFU identity is invalid");
|
||||
}
|
||||
if (
|
||||
input.retainedAnchor &&
|
||||
(!input.retainedAnchor.childPeerId ||
|
||||
!input.retainedAnchor.childSessionId)
|
||||
) {
|
||||
throw new Error("Codec SFU retained anchor is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function compareParticipant(left: Participant, right: Participant): number {
|
||||
return left.joinOrder - right.joinOrder || left.peerId.localeCompare(right.peerId);
|
||||
}
|
||||
|
||||
+36
-372
@@ -15,10 +15,8 @@ import {
|
||||
VIEWER_QUALITY_EVIDENCE_INTERVAL_MS,
|
||||
decodeClientMessage,
|
||||
type ClientMessage,
|
||||
type CodecTransitionGeneration,
|
||||
type CodeEntryPolicy,
|
||||
type QualitySettings,
|
||||
type ResumeAttempt,
|
||||
type Role,
|
||||
type ServerMessage,
|
||||
type ParticipantPresenceEntry,
|
||||
@@ -34,7 +32,6 @@ import {
|
||||
HybridMediaRouter,
|
||||
type SfuFallbackOptions,
|
||||
} from "./hybrid-media-router.js";
|
||||
import { CodecTransitionCoordinator } from "./codec-transition.js";
|
||||
import { createIceConfig, type IceConfigOptions } from "./ice.js";
|
||||
|
||||
type ErrorCode = Extract<ServerMessage, { type: "error" }>["code"];
|
||||
@@ -130,7 +127,6 @@ export class SignalingServer {
|
||||
Set<string>
|
||||
>();
|
||||
private readonly hybridMediaRouter?: HybridMediaRouter;
|
||||
private readonly codecTransitions?: CodecTransitionCoordinator;
|
||||
private readonly now: () => number;
|
||||
private readonly authenticationTimeoutMs: number;
|
||||
private readonly viewerDisconnectGraceMs: number;
|
||||
@@ -181,7 +177,6 @@ export class SignalingServer {
|
||||
getShareGeneration: (roomId) => this.shareGenerationsByRoom.get(roomId),
|
||||
onActiveRouteChanged: (roomId) => {
|
||||
this.viewerMediaReadyByRoom.delete(roomId);
|
||||
this.codecTransitions?.participantChanged(roomId);
|
||||
this.sendViewerPresence(roomId);
|
||||
},
|
||||
onViewerMediaState: (
|
||||
@@ -203,81 +198,6 @@ export class SignalingServer {
|
||||
now: this.now,
|
||||
});
|
||||
}
|
||||
this.codecTransitions = new CodecTransitionCoordinator({
|
||||
snapshot: (roomId) =>
|
||||
this.hybridMediaRouter?.codecRouteSnapshot(roomId) ?? {
|
||||
revision: 0,
|
||||
targets: [],
|
||||
},
|
||||
sendToSession: (sessionId, message) =>
|
||||
this.sendToSession(sessionId, message),
|
||||
commitCodec: (roomId, videoCodec) =>
|
||||
this.commitRoomVideoCodec(roomId, videoCodec),
|
||||
authorizeResume: (
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
) =>
|
||||
this.authorizeSharingResume(
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
),
|
||||
confirmSourceEnabled: (
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
) =>
|
||||
this.confirmSharingSourceEnabled(
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
),
|
||||
restoreAuthoritativePause: (
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
) =>
|
||||
this.restoreAuthoritativePause(
|
||||
roomId,
|
||||
hostSessionId,
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
),
|
||||
returnTargetsToRouteWait: (roomId, viewerPeerIds) =>
|
||||
this.hybridMediaRouter?.routeWaitInvalidCodecTargets(
|
||||
roomId,
|
||||
viewerPeerIds,
|
||||
),
|
||||
beginSfuReplacement: (input) =>
|
||||
this.hybridMediaRouter?.beginCodecSfuReplacement(input) ??
|
||||
Promise.resolve(null),
|
||||
parkSfuReplacement: (roomId, generation) =>
|
||||
this.hybridMediaRouter?.parkCodecSfuReplacement(roomId, generation) ??
|
||||
false,
|
||||
beginSfuProof: (roomId, generation, timeoutMs) =>
|
||||
this.hybridMediaRouter?.beginCodecSfuProof(
|
||||
roomId,
|
||||
generation,
|
||||
timeoutMs,
|
||||
) ?? false,
|
||||
abortSfuReplacement: (roomId, generation) =>
|
||||
this.hybridMediaRouter?.abortCodecSfuReplacement(roomId, generation),
|
||||
releaseRoutePause: (roomId) =>
|
||||
this.hybridMediaRouter?.setPaused(roomId, false),
|
||||
now: this.now,
|
||||
});
|
||||
|
||||
this.upgradeHandler = (request, socket, head) => {
|
||||
let requestUrl: URL;
|
||||
@@ -338,7 +258,6 @@ export class SignalingServer {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.viewerGraceTimers.clear();
|
||||
this.codecTransitions?.close();
|
||||
let routeCloseError: unknown;
|
||||
try {
|
||||
await this.hybridMediaRouter?.close();
|
||||
@@ -579,10 +498,28 @@ export class SignalingServer {
|
||||
) {
|
||||
this.stopSharing(participant.roomId);
|
||||
}
|
||||
if (
|
||||
message.shareGeneration !== undefined &&
|
||||
(currentGeneration !== shareGeneration ||
|
||||
!this.qualitySettingsByRoom.has(participant.roomId))
|
||||
) {
|
||||
const initialQualitySettings =
|
||||
message.qualitySettings ?? DEFAULT_QUALITY_SETTINGS;
|
||||
this.qualitySettingsByRoom.set(participant.roomId, {
|
||||
...initialQualitySettings,
|
||||
});
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(
|
||||
participant.roomId,
|
||||
)) {
|
||||
this.sendToSession(viewer.sessionId, {
|
||||
type: "quality-settings",
|
||||
qualitySettings: initialQualitySettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.shareGenerationsByRoom.set(participant.roomId, shareGeneration);
|
||||
if (
|
||||
message.sharingPaused === true ||
|
||||
this.codecTransitions?.blocksResume(participant.roomId) ||
|
||||
sameShareWasAuthoritativelyPaused
|
||||
) {
|
||||
this.pausedShareGenerationsByRoom.set(
|
||||
@@ -723,12 +660,7 @@ export class SignalingServer {
|
||||
}
|
||||
|
||||
if (participant.role === "host") {
|
||||
if (this.codecTransitions?.blocksResume(participant.roomId)) {
|
||||
this.codecTransitions.invalidateHostSession(
|
||||
participant.roomId,
|
||||
state.sessionId,
|
||||
);
|
||||
} else if (
|
||||
if (
|
||||
shareGeneration !== null &&
|
||||
this.pausedShareGenerationsByRoom.get(participant.roomId) ===
|
||||
shareGeneration
|
||||
@@ -736,8 +668,6 @@ export class SignalingServer {
|
||||
this.sendToSession(state.sessionId, {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -869,7 +799,7 @@ export class SignalingServer {
|
||||
this.sendError(
|
||||
socket,
|
||||
"FORBIDDEN",
|
||||
"视频编码只能在分享暂停后切换",
|
||||
"视频编码只能在开始分享前选择;请停止分享后重新开始",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1072,175 +1002,26 @@ export class SignalingServer {
|
||||
socket.close(SIGNAL_CLOSE_CODES.sessionReplaced, "Sharing generation replaced");
|
||||
return;
|
||||
}
|
||||
this.pausedShareGenerationsByRoom.set(
|
||||
authenticated.roomId,
|
||||
authenticated.shareGeneration,
|
||||
);
|
||||
this.codecTransitions?.pause({
|
||||
roomId: authenticated.roomId,
|
||||
hostSessionId: pauseSocketState.sessionId,
|
||||
shareGeneration: authenticated.shareGeneration,
|
||||
});
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.setPaused(authenticated.roomId, true);
|
||||
}
|
||||
this.broadcastHostStatus(authenticated.roomId, true, true);
|
||||
return;
|
||||
case "request-sharing-resume": {
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(socket, "FORBIDDEN", "只有当前分享者可以恢复分享");
|
||||
return;
|
||||
}
|
||||
const resumeState = this.socketStates.get(socket);
|
||||
const currentHost = this.options.roomStore.getConnectedHost(
|
||||
authenticated.roomId,
|
||||
);
|
||||
if (
|
||||
!resumeState ||
|
||||
currentHost?.sessionId !== resumeState.sessionId ||
|
||||
authenticated.shareGeneration === null ||
|
||||
this.shareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
this.pausedShareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
message.shareGeneration !== authenticated.shareGeneration
|
||||
) {
|
||||
socket.close(SIGNAL_CLOSE_CODES.sessionReplaced, "Sharing generation replaced");
|
||||
return;
|
||||
}
|
||||
const result = this.codecTransitions!.requestResume({
|
||||
roomId: authenticated.roomId,
|
||||
hostSessionId: resumeState.sessionId,
|
||||
shareGeneration: authenticated.shareGeneration,
|
||||
});
|
||||
if (result === "busy") {
|
||||
this.sendError(
|
||||
socket,
|
||||
"FORBIDDEN",
|
||||
"视频编码尚未准备完成",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "sharing-source-enabled": {
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(socket, "FORBIDDEN", "只有当前分享者可以确认恢复分享");
|
||||
return;
|
||||
}
|
||||
const sourceState = this.socketStates.get(socket);
|
||||
const currentHost = this.options.roomStore.getConnectedHost(
|
||||
authenticated.roomId,
|
||||
);
|
||||
if (
|
||||
!sourceState ||
|
||||
currentHost?.sessionId !== sourceState.sessionId ||
|
||||
authenticated.shareGeneration === null ||
|
||||
this.shareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
message.shareGeneration !== authenticated.shareGeneration
|
||||
) {
|
||||
socket.close(SIGNAL_CLOSE_CODES.sessionReplaced, "Sharing generation replaced");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.codecTransitions!.sourceEnabled({
|
||||
roomId: authenticated.roomId,
|
||||
hostSessionId: sourceState.sessionId,
|
||||
shareGeneration: authenticated.shareGeneration,
|
||||
codecGeneration: message.codecGeneration,
|
||||
resumeAttempt: message.resumeAttempt,
|
||||
})
|
||||
) {
|
||||
this.rejectSharingSourceAcknowledgement(
|
||||
if (message.paused) {
|
||||
this.pausedShareGenerationsByRoom.set(
|
||||
authenticated.roomId,
|
||||
sourceState.sessionId,
|
||||
authenticated.shareGeneration,
|
||||
message.codecGeneration,
|
||||
message.resumeAttempt,
|
||||
);
|
||||
} else {
|
||||
this.pausedShareGenerationsByRoom.delete(authenticated.roomId);
|
||||
}
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.setPaused(
|
||||
authenticated.roomId,
|
||||
message.paused,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "request-video-codec-transition": {
|
||||
if (
|
||||
authenticated.role !== "host" ||
|
||||
!this.isHybridMediaEnabled()
|
||||
) {
|
||||
this.sendError(socket, "FORBIDDEN", "只有当前分享者可以切换视频编码");
|
||||
return;
|
||||
}
|
||||
const transitionState = this.socketStates.get(socket);
|
||||
const currentHost = this.options.roomStore.getConnectedHost(
|
||||
this.broadcastHostStatus(
|
||||
authenticated.roomId,
|
||||
true,
|
||||
message.paused,
|
||||
);
|
||||
if (
|
||||
!transitionState ||
|
||||
currentHost?.sessionId !== transitionState.sessionId ||
|
||||
authenticated.shareGeneration === null ||
|
||||
this.shareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
this.pausedShareGenerationsByRoom.get(authenticated.roomId) !==
|
||||
authenticated.shareGeneration ||
|
||||
message.shareGeneration !== authenticated.shareGeneration
|
||||
) {
|
||||
socket.close(SIGNAL_CLOSE_CODES.sessionReplaced, "Sharing generation replaced");
|
||||
return;
|
||||
}
|
||||
const previous = videoCodec(
|
||||
this.qualitySettingsByRoom.get(authenticated.roomId) ??
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
);
|
||||
if (previous === message.videoCodec) {
|
||||
return;
|
||||
}
|
||||
const result = this.codecTransitions!.start({
|
||||
roomId: authenticated.roomId,
|
||||
hostSessionId: transitionState.sessionId,
|
||||
shareGeneration: authenticated.shareGeneration,
|
||||
requested: message.videoCodec,
|
||||
previous,
|
||||
});
|
||||
if (result === "busy" || result === "unavailable") {
|
||||
this.sendError(
|
||||
socket,
|
||||
result === "busy" ? "FORBIDDEN" : "SERVER_ERROR",
|
||||
result === "busy"
|
||||
? "已有视频编码切换正在进行"
|
||||
: "当前媒体线路不可用",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "video-codec-prepared": {
|
||||
if (!this.isHybridMediaEnabled()) {
|
||||
this.sendError(socket, "FORBIDDEN", "当前未启用媒体线路");
|
||||
return;
|
||||
}
|
||||
const state = this.socketStates.get(socket);
|
||||
if (state?.authenticated === authenticated) {
|
||||
this.codecTransitions!.prepared(
|
||||
authenticated.roomId,
|
||||
state.sessionId,
|
||||
message,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "video-codec-proof": {
|
||||
if (!this.isHybridMediaEnabled() || authenticated.role !== "viewer") {
|
||||
this.sendError(socket, "FORBIDDEN", "只有当前观看者可以确认已解码画面");
|
||||
return;
|
||||
}
|
||||
const state = this.socketStates.get(socket);
|
||||
if (state?.authenticated === authenticated) {
|
||||
this.codecTransitions!.proof(
|
||||
authenticated.roomId,
|
||||
state.sessionId,
|
||||
message,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "stop-sharing":
|
||||
if (authenticated.role !== "host") {
|
||||
this.sendError(socket, "FORBIDDEN", "只有当前分享者可以停止分享");
|
||||
@@ -1764,14 +1545,11 @@ export class SignalingServer {
|
||||
);
|
||||
}
|
||||
if (disconnected.role === "host") {
|
||||
this.codecTransitions?.invalidateHostSession(disconnected.roomId);
|
||||
this.broadcastHostStatus(disconnected.roomId, false, false);
|
||||
this.sendViewerPresence(disconnected.roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.codecTransitions?.participantChanged(disconnected.roomId);
|
||||
|
||||
this.sendViewerPresence(disconnected.roomId);
|
||||
|
||||
const key = viewerGraceKey(disconnected.roomId, disconnected.peerId);
|
||||
@@ -1821,7 +1599,7 @@ export class SignalingServer {
|
||||
}
|
||||
|
||||
private stopSharing(roomId: string): void {
|
||||
this.codecTransitions?.clearRoom(roomId);
|
||||
this.qualitySettingsByRoom.delete(roomId);
|
||||
this.pausedShareGenerationsByRoom.delete(roomId);
|
||||
this.clearRoomConnectionIds(roomId);
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
@@ -1845,7 +1623,6 @@ export class SignalingServer {
|
||||
this.clearRoomGraceTimers(roomId);
|
||||
this.clearRoomConnectionIds(roomId);
|
||||
this.ordinaryActiveHostChildrenByRoom.delete(roomId);
|
||||
this.codecTransitions?.clearRoom(roomId);
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.deleteRoom(roomId);
|
||||
}
|
||||
@@ -1867,7 +1644,6 @@ export class SignalingServer {
|
||||
this.clearRoomGraceTimers(expired.roomId);
|
||||
this.clearRoomConnectionIds(expired.roomId);
|
||||
this.ordinaryActiveHostChildrenByRoom.delete(expired.roomId);
|
||||
this.codecTransitions?.clearRoom(expired.roomId);
|
||||
if (this.isHybridMediaEnabled()) {
|
||||
this.hybridMediaRouter!.deleteRoom(expired.roomId);
|
||||
}
|
||||
@@ -2164,118 +1940,6 @@ export class SignalingServer {
|
||||
}
|
||||
}
|
||||
|
||||
private commitRoomVideoCodec(
|
||||
roomId: string,
|
||||
nextVideoCodec: VideoCodecPreference,
|
||||
): void {
|
||||
const qualitySettings = {
|
||||
...(this.qualitySettingsByRoom.get(roomId) ?? DEFAULT_QUALITY_SETTINGS),
|
||||
videoCodec: nextVideoCodec,
|
||||
};
|
||||
this.qualitySettingsByRoom.set(roomId, qualitySettings);
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) {
|
||||
this.sendToSession(viewer.sessionId, {
|
||||
type: "quality-settings",
|
||||
qualitySettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private authorizeSharingResume(
|
||||
roomId: string,
|
||||
hostSessionId: string,
|
||||
shareGeneration: string,
|
||||
codecGeneration: CodecTransitionGeneration | null,
|
||||
resumeAttempt: ResumeAttempt,
|
||||
): void {
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (
|
||||
host?.sessionId !== hostSessionId ||
|
||||
this.shareGenerationsByRoom.get(roomId) !== shareGeneration ||
|
||||
this.pausedShareGenerationsByRoom.get(roomId) !== shareGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sendToSession(hostSessionId, {
|
||||
type: "sharing-resume-authorized",
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
});
|
||||
}
|
||||
|
||||
private confirmSharingSourceEnabled(
|
||||
roomId: string,
|
||||
hostSessionId: string,
|
||||
shareGeneration: string,
|
||||
_codecGeneration: CodecTransitionGeneration | null,
|
||||
_resumeAttempt: ResumeAttempt,
|
||||
): boolean {
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (
|
||||
host?.sessionId !== hostSessionId ||
|
||||
this.shareGenerationsByRoom.get(roomId) !== shareGeneration ||
|
||||
this.pausedShareGenerationsByRoom.get(roomId) !== shareGeneration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.pausedShareGenerationsByRoom.delete(roomId);
|
||||
this.sendToSession(hostSessionId, {
|
||||
type: "host-status",
|
||||
online: true,
|
||||
paused: false,
|
||||
});
|
||||
this.broadcastHostStatus(roomId, true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private restoreAuthoritativePause(
|
||||
roomId: string,
|
||||
hostSessionId: string,
|
||||
shareGeneration: string,
|
||||
codecGeneration: CodecTransitionGeneration | null,
|
||||
resumeAttempt: ResumeAttempt | null,
|
||||
): void {
|
||||
if (this.shareGenerationsByRoom.get(roomId) !== shareGeneration) {
|
||||
return;
|
||||
}
|
||||
this.pausedShareGenerationsByRoom.set(roomId, shareGeneration);
|
||||
this.hybridMediaRouter?.setPaused(roomId, true);
|
||||
const host = this.options.roomStore.getConnectedHost(roomId);
|
||||
if (host?.sessionId === hostSessionId) {
|
||||
this.sendToSession(hostSessionId, {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
});
|
||||
this.broadcastHostStatus(roomId, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectSharingSourceAcknowledgement(
|
||||
roomId: string,
|
||||
hostSessionId: string,
|
||||
shareGeneration: string,
|
||||
codecGeneration: CodecTransitionGeneration | null,
|
||||
resumeAttempt: ResumeAttempt,
|
||||
): void {
|
||||
if (
|
||||
this.shareGenerationsByRoom.get(roomId) !== shareGeneration ||
|
||||
this.pausedShareGenerationsByRoom.get(roomId) !== shareGeneration ||
|
||||
this.options.roomStore.getConnectedHost(roomId)?.sessionId !==
|
||||
hostSessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sendToSession(hostSessionId, {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration,
|
||||
codecGeneration,
|
||||
resumeAttempt,
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastHostStatus(
|
||||
roomId: string,
|
||||
online: boolean,
|
||||
@@ -2423,7 +2087,7 @@ function viewerConnectionKey(roomId: string, peerId: string): string {
|
||||
}
|
||||
|
||||
function videoCodec(settings: QualitySettings): VideoCodecPreference {
|
||||
return settings.videoCodec ?? "automatic";
|
||||
return settings.videoCodec ?? DEFAULT_QUALITY_SETTINGS.videoCodec;
|
||||
}
|
||||
|
||||
function authenticationErrorMessage(code: ErrorCode): string {
|
||||
|
||||
+4
-182
@@ -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-v9";
|
||||
export const SIGNALING_PROTOCOL = "screener-v10";
|
||||
export const SIGNAL_CLOSE_CODES = {
|
||||
sessionReplaced: 4001,
|
||||
clientReconnect: 4002,
|
||||
@@ -188,85 +188,6 @@ export type VideoCodecPreference = z.infer<
|
||||
typeof videoCodecPreferenceSchema
|
||||
>;
|
||||
|
||||
export const codecTransitionGenerationSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER);
|
||||
export type CodecTransitionGeneration = z.infer<
|
||||
typeof codecTransitionGenerationSchema
|
||||
>;
|
||||
export const resumeAttemptSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER);
|
||||
export type ResumeAttempt = z.infer<typeof resumeAttemptSchema>;
|
||||
export const codecPreparationBindingSchema = z.discriminatedUnion("kind", [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("peer"),
|
||||
childPeerId: opaqueIdSchema,
|
||||
connectionId: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("sfu"),
|
||||
publicationGeneration: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export type CodecPreparationBinding = z.infer<
|
||||
typeof codecPreparationBindingSchema
|
||||
>;
|
||||
|
||||
export const codecProofBindingSchema = z.discriminatedUnion("kind", [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("peer"),
|
||||
connectionId: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("sfu"),
|
||||
connectionId: opaqueIdSchema,
|
||||
publicationGeneration: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export type CodecProofBinding = z.infer<typeof codecProofBindingSchema>;
|
||||
|
||||
const codecProofTimestampSchema = z
|
||||
.number()
|
||||
.finite()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER);
|
||||
const codecProofIdentityStringSchema = z.string().min(1).max(512);
|
||||
export const codecProofEvidenceSchema = z
|
||||
.object({
|
||||
baselineSampleTimestampMs: codecProofTimestampSchema,
|
||||
sampleTimestampMs: codecProofTimestampSchema,
|
||||
rtpStatsId: codecProofIdentityStringSchema,
|
||||
rtpSsrc: z.number().int().min(0).max(0xffff_ffff).nullable(),
|
||||
rtpMid: codecProofIdentityStringSchema.nullable(),
|
||||
rtpRid: codecProofIdentityStringSchema.nullable(),
|
||||
trackIdentifier: codecProofIdentityStringSchema.nullable(),
|
||||
framesDecodedDelta: z.number().int().min(1).max(10_000),
|
||||
actualCodec: z.enum(["h264", "vp8", "other"]),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(evidence) =>
|
||||
evidence.sampleTimestampMs > evidence.baselineSampleTimestampMs,
|
||||
{
|
||||
message: "Codec proof sample must follow its decoded-frame baseline",
|
||||
path: ["sampleTimestampMs"],
|
||||
},
|
||||
);
|
||||
export type CodecProofEvidence = z.infer<typeof codecProofEvidenceSchema>;
|
||||
|
||||
export const screenAudioQualitySchema = z.enum([
|
||||
"saver",
|
||||
"music",
|
||||
@@ -290,7 +211,7 @@ export const DEFAULT_QUALITY_SETTINGS = {
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "balanced",
|
||||
videoCodec: "automatic",
|
||||
videoCodec: "vp8",
|
||||
screenAudioQuality: "music",
|
||||
} as const satisfies QualitySettings;
|
||||
|
||||
@@ -379,7 +300,6 @@ export const signalPayloadSchema = z.discriminatedUnion("kind", [
|
||||
.object({
|
||||
kind: z.literal("description"),
|
||||
connectionId: opaqueIdSchema,
|
||||
negotiationGeneration: codecTransitionGenerationSchema.nullable(),
|
||||
description: sessionDescriptionSchema,
|
||||
})
|
||||
.strict(),
|
||||
@@ -398,13 +318,6 @@ export const preparedRouteCandidateSchema = z
|
||||
childPeerId: opaqueIdSchema,
|
||||
connectionId: opaqueIdSchema,
|
||||
transport: z.enum(["direct", "sfu"]),
|
||||
codecTransition: z
|
||||
.object({
|
||||
generation: codecTransitionGenerationSchema,
|
||||
videoCodec: videoCodecPreferenceSchema,
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
})
|
||||
.strict();
|
||||
export type PreparedRouteCandidate = z.infer<
|
||||
@@ -699,6 +612,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
clientId: opaqueIdSchema,
|
||||
shareGeneration: opaqueIdSchema.optional(),
|
||||
sharingPaused: z.boolean().optional(),
|
||||
qualitySettings: qualitySettingsSchema.optional(),
|
||||
viewerPresence: z.literal(true).optional(),
|
||||
viewerPasswordSettings: z.literal(true).optional(),
|
||||
displayName: displayNameSchema.optional(),
|
||||
@@ -821,48 +735,7 @@ export const clientMessageSchema = z.union([
|
||||
.object({
|
||||
type: z.literal("set-sharing-paused"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
paused: z.literal(true),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("request-sharing-resume"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("sharing-source-enabled"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
codecGeneration: codecTransitionGenerationSchema.nullable(),
|
||||
resumeAttempt: resumeAttemptSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("request-video-codec-transition"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
videoCodec: videoCodecPreferenceSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("video-codec-prepared"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
generation: codecTransitionGenerationSchema,
|
||||
binding: codecPreparationBindingSchema,
|
||||
accepted: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("video-codec-proof"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
generation: codecTransitionGenerationSchema,
|
||||
resumeAttempt: resumeAttemptSchema,
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
binding: codecProofBindingSchema,
|
||||
evidence: codecProofEvidenceSchema,
|
||||
paused: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
@@ -1028,61 +901,10 @@ export const serverMessageSchema = z.union([
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("video-codec-prepare"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
generation: codecTransitionGenerationSchema,
|
||||
videoCodec: videoCodecPreferenceSchema,
|
||||
binding: codecPreparationBindingSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("video-codec-proof-request"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
generation: codecTransitionGenerationSchema,
|
||||
resumeAttempt: resumeAttemptSchema,
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
expectedCodec: z.enum(["h264", "vp8"]).nullable(),
|
||||
binding: codecProofBindingSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("video-codec-result"),
|
||||
generation: codecTransitionGenerationSchema,
|
||||
videoCodec: videoCodecPreferenceSchema,
|
||||
status: z.enum([
|
||||
"prepared",
|
||||
"committed",
|
||||
"rollback-prepared",
|
||||
"failed",
|
||||
]),
|
||||
failure: z
|
||||
.enum([
|
||||
"preparation-failed",
|
||||
"proof-failed",
|
||||
"rollback-failed",
|
||||
"stale-binding",
|
||||
])
|
||||
.nullable(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("sharing-resume-authorized"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
codecGeneration: codecTransitionGenerationSchema.nullable(),
|
||||
resumeAttempt: resumeAttemptSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("pause-sharing-source"),
|
||||
shareGeneration: opaqueIdSchema,
|
||||
codecGeneration: codecTransitionGenerationSchema.nullable(),
|
||||
resumeAttempt: resumeAttemptSchema.nullable(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
|
||||
+13
-25
@@ -746,7 +746,7 @@ describe("client signaling recovery policy", () => {
|
||||
Object.defineProperty(authenticated, "data", {
|
||||
value: JSON.stringify({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -767,7 +767,7 @@ describe("client signaling recovery policy", () => {
|
||||
expect(signal.reconnect()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a Host paused across reconnect until the server confirms Resume", () => {
|
||||
it("keeps a Host paused across reconnect until an explicit Resume is sent", () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
class FakeWebSocket extends EventTarget {
|
||||
@@ -815,7 +815,7 @@ describe("client signaling recovery policy", () => {
|
||||
socket.dispatchEvent(new Event("open"));
|
||||
receive(socket, {
|
||||
type: "authenticated",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
role: "host",
|
||||
peerId: "host_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -843,21 +843,6 @@ describe("client signaling recovery policy", () => {
|
||||
|
||||
signal.start();
|
||||
authenticate(sockets[0]!);
|
||||
expect(
|
||||
signal.confirmSharingResumed({
|
||||
type: "sharing-resume-authorized",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
codecGeneration: null,
|
||||
resumeAttempt: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0])),
|
||||
).toMatchObject({
|
||||
type: "sharing-source-enabled",
|
||||
resumeAttempt: 1,
|
||||
});
|
||||
|
||||
reconnect(sockets[0]!);
|
||||
sockets[1]!.dispatchEvent(new Event("open"));
|
||||
expect(JSON.parse(String(sockets[1]!.send.mock.calls[0]![0]))).toMatchObject({
|
||||
@@ -866,7 +851,7 @@ describe("client signaling recovery policy", () => {
|
||||
});
|
||||
receive(sockets[1]!, {
|
||||
type: "authenticated",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
role: "host",
|
||||
peerId: "host_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -880,9 +865,12 @@ describe("client signaling recovery policy", () => {
|
||||
codeEntryPolicy: "open",
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
});
|
||||
receive(sockets[1]!, {
|
||||
type: "host-status",
|
||||
online: true,
|
||||
expect(signal.setSharingPaused(false)).toBe(true);
|
||||
expect(
|
||||
JSON.parse(String(sockets[1]!.send.mock.calls.at(-1)![0])),
|
||||
).toMatchObject({
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
paused: false,
|
||||
});
|
||||
|
||||
@@ -948,7 +936,7 @@ describe("client signaling recovery policy", () => {
|
||||
socket.dispatchEvent(new Event("open"));
|
||||
receive(socket, {
|
||||
type: "authenticated",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
role: "host",
|
||||
peerId: "host_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -1073,7 +1061,7 @@ describe("client signaling recovery policy", () => {
|
||||
sockets[0]!.dispatchEvent(new Event("open"));
|
||||
receive({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -1201,7 +1189,7 @@ describe("client signaling recovery policy", () => {
|
||||
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
|
||||
).toMatchObject({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v9",
|
||||
protocol: "screener-v10",
|
||||
});
|
||||
const message = new Event("message");
|
||||
Object.defineProperty(message, "data", { value: payload });
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
CodecProofReporter,
|
||||
type CodecProofRouteSample,
|
||||
} from "../src/client/media/codec-proof.ts";
|
||||
import { EMPTY_METRICS } from "../src/client/types.ts";
|
||||
import type { ClientMessage, ServerMessage } from "../src/shared/protocol.ts";
|
||||
|
||||
const request = {
|
||||
type: "video-codec-proof-request",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
generation: 1,
|
||||
resumeAttempt: 3,
|
||||
routeRevision: 7,
|
||||
expectedCodec: "vp8",
|
||||
binding: { kind: "peer", connectionId: "connection_12345678" },
|
||||
} satisfies Extract<ServerMessage, { type: "video-codec-proof-request" }>;
|
||||
|
||||
function sample(
|
||||
timestamp: number,
|
||||
framesDecodedDelta: number | null,
|
||||
rtpStatsId = "inbound_video_12345678",
|
||||
): CodecProofRouteSample {
|
||||
return {
|
||||
routeRevision: 7,
|
||||
binding: { kind: "peer", connectionId: "connection_12345678" },
|
||||
metrics: {
|
||||
...EMPTY_METRICS,
|
||||
sampleTimestampMs: timestamp,
|
||||
rtpStatsId,
|
||||
rtpSsrc: 42,
|
||||
rtpMid: "0",
|
||||
trackIdentifier: "track_12345678",
|
||||
intervalFramesDecoded: framesDecodedDelta,
|
||||
codec: "video/VP8",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CodecProofReporter", () => {
|
||||
it("proves only a later decoded sample on the exact frozen binding", () => {
|
||||
const sent: ClientMessage[] = [];
|
||||
const reporter = new CodecProofReporter((message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(reporter.request(request)).toBe(true);
|
||||
reporter.offer(sample(100, 1));
|
||||
reporter.offer(sample(200, 0));
|
||||
expect(sent).toEqual([]);
|
||||
reporter.offer(sample(300, 5));
|
||||
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "video-codec-proof",
|
||||
generation: 1,
|
||||
resumeAttempt: 3,
|
||||
routeRevision: 7,
|
||||
evidence: expect.objectContaining({
|
||||
baselineSampleTimestampMs: 100,
|
||||
sampleTimestampMs: 300,
|
||||
framesDecodedDelta: 5,
|
||||
actualCodec: "vp8",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-baselines when the inbound RTP stats object changes", () => {
|
||||
const send = vi.fn((_message: ClientMessage) => true);
|
||||
const reporter = new CodecProofReporter(send);
|
||||
reporter.request(request);
|
||||
|
||||
reporter.offer(sample(100, 0, "old_inbound_12345678"));
|
||||
reporter.offer(sample(200, 1, "new_inbound_12345678"));
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
reporter.offer(sample(300, 2, "new_inbound_12345678"));
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains a request until an exact-route baseline becomes available", () => {
|
||||
const send = vi.fn(() => true);
|
||||
const reporter = new CodecProofReporter(send);
|
||||
expect(reporter.request(request)).toBe(true);
|
||||
reporter.offer({ ...sample(150, 1), routeRevision: 8 });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
reporter.offer(sample(200, 0));
|
||||
reporter.offer(sample(300, 2));
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores out-of-order samples without replacing the current baseline", () => {
|
||||
const send = vi.fn((_message: ClientMessage) => true);
|
||||
const reporter = new CodecProofReporter(send);
|
||||
reporter.request(request);
|
||||
|
||||
reporter.offer(sample(200, 0));
|
||||
reporter.offer(sample(100, 1, "older_inbound_12345678"));
|
||||
reporter.offer(sample(300, 2));
|
||||
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
type: "video-codec-proof",
|
||||
evidence: {
|
||||
baselineSampleTimestampMs: 200,
|
||||
sampleTimestampMs: 300,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first exact sample after the proof request only as a baseline", () => {
|
||||
const send = vi.fn((_message: ClientMessage) => true);
|
||||
const reporter = new CodecProofReporter(send);
|
||||
|
||||
reporter.request(request);
|
||||
reporter.offer(sample(200, 1));
|
||||
reporter.offer(sample(300, 0));
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
reporter.offer(sample(400, 2));
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
type: "video-codec-proof",
|
||||
resumeAttempt: 3,
|
||||
evidence: {
|
||||
baselineSampleTimestampMs: 200,
|
||||
sampleTimestampMs: 400,
|
||||
framesDecodedDelta: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,6 @@ import {
|
||||
hostServerErrorNotice,
|
||||
shouldPauseLocalPreview,
|
||||
sourceSwitchNotice,
|
||||
VIDEO_CODEC_TRANSITION_FAILED_NOTICE,
|
||||
videoCodecLockNotice,
|
||||
} from "../src/client/pages/host-page-notices.ts";
|
||||
|
||||
@@ -97,22 +96,14 @@ describe("sourceSwitchNotice", () => {
|
||||
});
|
||||
|
||||
describe("videoCodecLockNotice", () => {
|
||||
it("exposes codec switching only after the active share is paused", () => {
|
||||
expect(videoCodecLockNotice("starting", false)).toBe(
|
||||
"分享开始后,暂停分享即可切换视频编码",
|
||||
);
|
||||
expect(videoCodecLockNotice("live", false)).toBe(
|
||||
"暂停分享后可切换视频编码",
|
||||
);
|
||||
expect(videoCodecLockNotice("live", true)).toBeNull();
|
||||
it("keeps codec selection pre-share only", () => {
|
||||
for (const phase of ["starting", "live"]) {
|
||||
expect(videoCodecLockNotice(phase)).toBe(
|
||||
"本次分享的视频编码已固定,停止分享后可更改",
|
||||
);
|
||||
}
|
||||
for (const phase of ["idle", "ended", "error"]) {
|
||||
expect(videoCodecLockNotice(phase, false)).toBeNull();
|
||||
expect(videoCodecLockNotice(phase)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a failed codec transaction visible until sharing stops", () => {
|
||||
expect(videoCodecLockNotice("live", true, "failed")).toBe(
|
||||
VIDEO_CODEC_TRANSITION_FAILED_NOTICE,
|
||||
);
|
||||
});
|
||||
});
|
||||
+17
-315
@@ -11,7 +11,6 @@ import { ViewerRelay } from "../src/client/webrtc/viewer-relay.ts";
|
||||
import type {
|
||||
IceConfig,
|
||||
ParticipantRouteAssignment,
|
||||
SignalPayload,
|
||||
} from "../src/shared/protocol.ts";
|
||||
|
||||
const statsCallbacks: Array<() => void> = [];
|
||||
@@ -393,12 +392,27 @@ describe("HostPeer source replacement", () => {
|
||||
expect(updates.at(-1)?.error).not.toContain("createOffer failed");
|
||||
});
|
||||
|
||||
it("leaves browser codec ordering unchanged", async () => {
|
||||
it("prefers VP8 before the first offer by default", async () => {
|
||||
const peer = createPeer(createStream(createTrack("video", "video"), null));
|
||||
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
|
||||
expect(FakePeerConnection.latest!.codecPreferenceCalls).toEqual([]);
|
||||
expect(
|
||||
FakePeerConnection.latest!.codecPreferenceCalls[0]?.[0]?.mimeType,
|
||||
).toBe("video/VP8");
|
||||
});
|
||||
|
||||
it("restores browser codec ordering for explicit automatic", async () => {
|
||||
const peer = createPeer(
|
||||
createStream(createTrack("video", "video"), null),
|
||||
() => undefined,
|
||||
{ iceServers: [] },
|
||||
{ ...QUALITY_PROFILES["720p30"], videoCodec: "automatic" },
|
||||
);
|
||||
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
|
||||
expect(FakePeerConnection.latest!.codecPreferenceCalls).toEqual([[]]);
|
||||
});
|
||||
|
||||
it.each(["h264", "vp8"] as const)(
|
||||
@@ -436,309 +450,6 @@ describe("HostPeer source replacement", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("supersedes an unanswered codec offer with the next exact generation", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [] },
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
sendSignal: (_peerId, payload) => {
|
||||
signals.push(payload);
|
||||
return true;
|
||||
},
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "initial-answer" },
|
||||
});
|
||||
|
||||
const first = peer.prepareVideoCodec(1, "h264");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 1,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
connection.signalingState = "have-local-offer";
|
||||
|
||||
const rollback = peer.prepareVideoCodec(2, "vp8");
|
||||
await expect(first).resolves.toBe(false);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 2,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
expect(connection.localDescription?.type).toBe("offer");
|
||||
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 1,
|
||||
description: { type: "answer", sdp: "stale-answer" },
|
||||
});
|
||||
let rollbackSettled = false;
|
||||
void rollback.then(() => {
|
||||
rollbackSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(rollbackSettled).toBe(false);
|
||||
|
||||
connection.signalingState = "stable";
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 2,
|
||||
description: { type: "answer", sdp: "rollback-answer" },
|
||||
});
|
||||
await expect(rollback).resolves.toBe(true);
|
||||
expect(connection.remoteDescription?.sdp).toBe("rollback-answer");
|
||||
expect(connection.codecPreferenceCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("serializes an in-flight codec answer before offering a newer generation", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [] },
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
sendSignal: (_peerId, payload) => {
|
||||
signals.push(payload);
|
||||
return true;
|
||||
},
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "initial-answer" },
|
||||
});
|
||||
signals.length = 0;
|
||||
|
||||
const first = peer.prepareVideoCodec(21, "h264");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 21,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
connection.deferRemoteDescriptionCall = 2;
|
||||
const staleAnswer = peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 21,
|
||||
description: { type: "answer", sdp: "generation-21-answer" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(connection.remoteDescriptionCallCount).toBe(2),
|
||||
);
|
||||
|
||||
const second = peer.prepareVideoCodec(22, "vp8");
|
||||
await expect(first).resolves.toBe(false);
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 22,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
connection.releaseDeferredRemoteDescription();
|
||||
await staleAnswer;
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 22,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 22,
|
||||
description: { type: "answer", sdp: "generation-22-answer" },
|
||||
});
|
||||
await expect(second).resolves.toBe(true);
|
||||
expect(connection.remoteDescription?.sdp).toBe("generation-22-answer");
|
||||
});
|
||||
|
||||
it("waits for the codec negotiation owner before sending an ICE restart offer", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [] },
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
sendSignal: (_peerId, payload) => {
|
||||
signals.push(payload);
|
||||
return true;
|
||||
},
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "initial-answer" },
|
||||
});
|
||||
signals.length = 0;
|
||||
|
||||
const codec = peer.prepareVideoCodec(31, "h264");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 31,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
const restart = peer.restartIce();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(connection.restartIce).not.toHaveBeenCalled();
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === null,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 31,
|
||||
description: { type: "answer", sdp: "codec-answer" },
|
||||
});
|
||||
await expect(codec).resolves.toBe(true);
|
||||
await expect(restart).resolves.toBe(true);
|
||||
expect(connection.restartIce).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
signals
|
||||
.filter((signal) => signal.kind === "description")
|
||||
.map((signal) => signal.negotiationGeneration),
|
||||
).toEqual([31, null]);
|
||||
});
|
||||
|
||||
it("rolls back a cancelled codec offer before an ordinary ICE restart", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [] },
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
sendSignal: (_peerId, payload) => {
|
||||
signals.push(payload);
|
||||
return true;
|
||||
},
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "initial-answer" },
|
||||
});
|
||||
signals.length = 0;
|
||||
|
||||
const codec = peer.prepareVideoCodec(41, "h264");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 41,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
connection.signalingState = "have-local-offer";
|
||||
peer.cancelVideoCodecPreparation();
|
||||
const restart = peer.restartIce();
|
||||
|
||||
await expect(codec).resolves.toBe(false);
|
||||
await expect(restart).resolves.toBe(true);
|
||||
expect(connection.signalingState).toBe("stable");
|
||||
expect(connection.restartIce).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
signals
|
||||
.filter((signal) => signal.kind === "description")
|
||||
.map((signal) => signal.negotiationGeneration),
|
||||
).toEqual([41, null]);
|
||||
});
|
||||
|
||||
it("coalesces an exact duplicate codec preparation", async () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [] },
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
sendSignal: (_peerId, payload) => {
|
||||
signals.push(payload);
|
||||
return true;
|
||||
},
|
||||
onUpdate: () => undefined,
|
||||
},
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
const first = peer.prepareVideoCodec(4, "h264");
|
||||
const duplicate = peer.prepareVideoCodec(4, "h264");
|
||||
|
||||
expect(duplicate).toBe(first);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
signals.filter(
|
||||
(signal) =>
|
||||
signal.kind === "description" &&
|
||||
signal.negotiationGeneration === 4,
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: 4,
|
||||
description: { type: "answer", sdp: "codec-answer" },
|
||||
});
|
||||
await expect(first).resolves.toBe(true);
|
||||
await expect(duplicate).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("applies STUN-only ICE configuration at creation and update", () => {
|
||||
const peer = createPeer(
|
||||
createStream(createTrack("video", "video"), null),
|
||||
@@ -974,7 +685,6 @@ describe("HostPeer source replacement", () => {
|
||||
await peer.acceptSignal({
|
||||
kind: "description",
|
||||
connectionId: peer.connectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "test-answer" },
|
||||
});
|
||||
|
||||
@@ -1436,7 +1146,6 @@ function hostProvisionalInput(
|
||||
childPeerId: childPeerIds.at(-1) ?? "candidate-child",
|
||||
connectionId: `candidate-connection-${revision}`,
|
||||
transport: "direct" as const,
|
||||
codecTransition: null,
|
||||
},
|
||||
assignment: hostAssignment(
|
||||
childPeerIds,
|
||||
@@ -1473,13 +1182,11 @@ describe("Host provisional child runtime ownership", () => {
|
||||
expect(owner.acceptSignal("wrong-child", {
|
||||
kind: "description",
|
||||
connectionId: preparedConnectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "wrong-peer" },
|
||||
})).toBe(false);
|
||||
expect(owner.acceptSignal("probe-child", {
|
||||
kind: "description",
|
||||
connectionId: "wrong-connection",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "wrong-connection" },
|
||||
})).toBe(false);
|
||||
expect(owner.acceptSignal("probe-child", {
|
||||
@@ -1490,7 +1197,6 @@ describe("Host provisional child runtime ownership", () => {
|
||||
expect(owner.acceptSignal("probe-child", {
|
||||
kind: "description",
|
||||
connectionId: preparedConnectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "right-answer" },
|
||||
})).toBe(true);
|
||||
await vi.waitFor(() =>
|
||||
@@ -1580,7 +1286,6 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
childPeerId,
|
||||
connectionId: `relay-candidate-${revision}`,
|
||||
transport: "direct" as const,
|
||||
codecTransition: null,
|
||||
});
|
||||
|
||||
it("promotes the exact prepared child connection within the current Viewer cap", async () => {
|
||||
@@ -1607,7 +1312,6 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
relay.acceptSignal("prepared-child", {
|
||||
kind: "description",
|
||||
connectionId: "wrong-connection",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "wrong-answer" },
|
||||
}, 7),
|
||||
).resolves.toBe(false);
|
||||
@@ -1615,7 +1319,6 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
relay.acceptSignal("prepared-child", {
|
||||
kind: "description",
|
||||
connectionId: preparedConnectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "right-answer" },
|
||||
}, 6),
|
||||
).resolves.toBe(false);
|
||||
@@ -1638,7 +1341,6 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
relay.acceptSignal("prepared-child", {
|
||||
kind: "description",
|
||||
connectionId: preparedConnectionId,
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "right-answer" },
|
||||
}, preparedRevision),
|
||||
).resolves.toBe(true);
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { applyAuthoritativeHostPause } from "../src/client/media/host-source-authority.ts";
|
||||
|
||||
function createStream() {
|
||||
const videoTrack = { enabled: true } as MediaStreamTrack;
|
||||
const audioTrack = { enabled: true } as MediaStreamTrack;
|
||||
const stream = {
|
||||
getTracks: () => [videoTrack, audioTrack],
|
||||
getVideoTracks: () => [videoTrack],
|
||||
} as unknown as MediaStream;
|
||||
return { stream, videoTrack, audioTrack };
|
||||
}
|
||||
|
||||
describe("Host source authority", () => {
|
||||
it("repause disables the current share after reconnect cleared local codec state", () => {
|
||||
const { stream, videoTrack, audioTrack } = createStream();
|
||||
const pauseSfuRoute = vi.fn();
|
||||
let hostPaused = false;
|
||||
|
||||
const result = applyAuthoritativeHostPause({
|
||||
message: {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
codecGeneration: 7,
|
||||
resumeAttempt: null,
|
||||
},
|
||||
currentShareGeneration: "share_generation_12345678",
|
||||
activeResumeAttempt: null,
|
||||
activeCodecGeneration: null,
|
||||
stream,
|
||||
pauseSfuRoute,
|
||||
markHostPaused: () => {
|
||||
hostPaused = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ localCodecContextMatches: false });
|
||||
expect(videoTrack.enabled).toBe(false);
|
||||
expect(audioTrack.enabled).toBe(false);
|
||||
expect(pauseSfuRoute).toHaveBeenCalledOnce();
|
||||
expect(hostPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a stale share before touching its source or route", () => {
|
||||
const { stream, videoTrack, audioTrack } = createStream();
|
||||
const pauseSfuRoute = vi.fn();
|
||||
const markHostPaused = vi.fn();
|
||||
|
||||
const result = applyAuthoritativeHostPause({
|
||||
message: {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration: "stale_share_generation_12345678",
|
||||
codecGeneration: 7,
|
||||
resumeAttempt: 3,
|
||||
},
|
||||
currentShareGeneration: "current_share_generation_12345678",
|
||||
activeResumeAttempt: 3,
|
||||
activeCodecGeneration: 7,
|
||||
stream,
|
||||
pauseSfuRoute,
|
||||
markHostPaused,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(videoTrack.enabled).toBe(true);
|
||||
expect(audioTrack.enabled).toBe(true);
|
||||
expect(pauseSfuRoute).not.toHaveBeenCalled();
|
||||
expect(markHostPaused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -65,34 +65,6 @@ function complete(
|
||||
return state;
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function reconnectHost(
|
||||
store: RoomStore,
|
||||
room: CreatedRoom,
|
||||
sessionId: string,
|
||||
): AuthenticatedRouteParticipant {
|
||||
const connected = store.connectParticipant({
|
||||
roomId: room.roomId,
|
||||
role: "host",
|
||||
token: room.hostToken,
|
||||
clientId: "host_client_12345678",
|
||||
sessionId,
|
||||
});
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
role: "host",
|
||||
peerId: connected.peerId,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function preparedFor(
|
||||
sent: Map<string, ServerMessage[]>,
|
||||
sessionId: string,
|
||||
@@ -679,330 +651,11 @@ describe("HybridMediaRouter v9 runtime", () => {
|
||||
await router.close();
|
||||
});
|
||||
|
||||
it("replaces an SFU publication through the codec-owned paused operation", async () => {
|
||||
const { store, sent, admission, roomControl, router } = harness(
|
||||
1,
|
||||
true,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
try {
|
||||
const room = await store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const first = connectViewer(store, room, "codec_sfu_first");
|
||||
complete(router, first);
|
||||
await vi.waitFor(() => expect(preparedFor(sent, first.sessionId)).toBeDefined());
|
||||
const firstDirect = preparedFor(sent, first.sessionId)!;
|
||||
expect(firstDirect.candidate.codecTransition).toBeNull();
|
||||
router.handleRouteReady(first, {
|
||||
type: "route-ready",
|
||||
revision: firstDirect.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
|
||||
const second = connectViewer(store, room, "codec_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 previous = router.codecRouteSnapshot(room.roomId)!;
|
||||
const previousPublication = (
|
||||
previous.targets[0]!.proofBinding as Extract<
|
||||
(typeof previous.targets)[number]["proofBinding"],
|
||||
{ kind: "sfu" }
|
||||
>
|
||||
).publicationGeneration;
|
||||
router.setPaused(room.roomId, true);
|
||||
const preparedSnapshot = await router.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: host.sessionId,
|
||||
generation: 1,
|
||||
videoCodec: "h264",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(preparedSnapshot).not.toBeNull();
|
||||
const freshTarget = preparedSnapshot!.targets.find(
|
||||
(target) => target.preparationBinding.kind === "sfu",
|
||||
)!;
|
||||
expect(freshTarget).toMatchObject({ proofReady: false });
|
||||
expect(freshTarget.preparationBinding).not.toMatchObject({
|
||||
publicationGeneration: previousPublication,
|
||||
});
|
||||
const codecPrepare = preparedFor(sent, freshTarget.viewerSessionId)!;
|
||||
expect(codecPrepare.candidate).toMatchObject({
|
||||
transport: "sfu",
|
||||
codecTransition: { generation: 1, videoCodec: "h264" },
|
||||
});
|
||||
expect(roomControl?.deleted).toContainEqual(
|
||||
expect.objectContaining({ publicationGeneration: previousPublication }),
|
||||
);
|
||||
expect(roomControl?.created).toHaveLength(2);
|
||||
|
||||
expect(router.parkCodecSfuReplacement(room.roomId, 1)).toBe(true);
|
||||
const anchor =
|
||||
freshTarget.viewerPeerId === first.peerId ? first : second;
|
||||
router.handleRouteReady(anchor, {
|
||||
type: "route-ready",
|
||||
revision: codecPrepare.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
expect(
|
||||
router.codecRouteSnapshot(room.roomId)?.targets.find(
|
||||
(target) => target.viewerPeerId === anchor.peerId,
|
||||
)?.proofReady,
|
||||
).toBe(false);
|
||||
|
||||
expect(router.beginCodecSfuProof(room.roomId, 1, 1_000)).toBe(true);
|
||||
router.handleRouteReady(anchor, {
|
||||
type: "route-ready",
|
||||
revision: codecPrepare.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
router.codecRouteSnapshot(room.roomId)?.targets.find(
|
||||
(target) => target.viewerPeerId === anchor.peerId,
|
||||
),
|
||||
).toMatchObject({ proofReady: true }),
|
||||
);
|
||||
expect(admission?.usage()).toEqual({ ingress: 1, egress: 1 });
|
||||
} finally {
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for exact old-generation drain before codec creation even with spare ingress", async () => {
|
||||
const { store, sent, admission, roomControl, router } = harness(
|
||||
1,
|
||||
true,
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
const drain = deferred();
|
||||
try {
|
||||
const room = await store.createRoom();
|
||||
const { host } = await establishSfuRoom(store, sent, router, room);
|
||||
await vi.waitFor(() =>
|
||||
expect(admission?.usage()).toEqual({ ingress: 1, egress: 2 }),
|
||||
);
|
||||
roomControl!.deleteBarrier = drain.promise;
|
||||
router.setPaused(room.roomId, true);
|
||||
let settled = false;
|
||||
const preparation = router
|
||||
.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: host.sessionId,
|
||||
generation: 1,
|
||||
videoCodec: "h264",
|
||||
timeoutMs: 1_000,
|
||||
})
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
Reflect.get(router, "resourceWaiters") as Map<string, unknown>
|
||||
).has(room.roomId),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(roomControl?.created).toHaveLength(1);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
drain.resolve();
|
||||
const snapshot = await preparation;
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(roomControl?.deleted).toHaveLength(1);
|
||||
expect(roomControl?.created).toHaveLength(2);
|
||||
} finally {
|
||||
drain.resolve();
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for an aborted fresh codec generation to drain before rollback at ingress two", async () => {
|
||||
const { store, sent, admission, roomControl, router } = harness(
|
||||
1,
|
||||
true,
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
const forwardDrain = deferred();
|
||||
try {
|
||||
const room = await store.createRoom();
|
||||
const { host } = await establishSfuRoom(store, sent, router, room);
|
||||
router.setPaused(room.roomId, true);
|
||||
const forward = await router.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: host.sessionId,
|
||||
generation: 1,
|
||||
videoCodec: "h264",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(forward).not.toBeNull();
|
||||
expect(roomControl?.created).toHaveLength(2);
|
||||
|
||||
roomControl!.deleteBarrier = forwardDrain.promise;
|
||||
router.abortCodecSfuReplacement(room.roomId, 1);
|
||||
let rollbackSettled = false;
|
||||
const rollback = router
|
||||
.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: host.sessionId,
|
||||
generation: 2,
|
||||
videoCodec: "automatic",
|
||||
timeoutMs: 1_000,
|
||||
})
|
||||
.finally(() => {
|
||||
rollbackSettled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
Reflect.get(router, "resourceWaiters") as Map<string, unknown>
|
||||
).has(room.roomId),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(admission?.usage()).toEqual({ ingress: 1, egress: 1 });
|
||||
expect(roomControl?.created).toHaveLength(2);
|
||||
expect(rollbackSettled).toBe(false);
|
||||
|
||||
forwardDrain.resolve();
|
||||
await expect(rollback).resolves.not.toBeNull();
|
||||
expect(roomControl?.created).toHaveLength(3);
|
||||
} finally {
|
||||
forwardDrain.resolve();
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a stale Host session failure for the current codec publication candidate", async () => {
|
||||
const { store, sent, router } = harness(1, true);
|
||||
try {
|
||||
const room = await store.createRoom();
|
||||
const { host: staleHost } = await establishSfuRoom(
|
||||
store,
|
||||
sent,
|
||||
router,
|
||||
room,
|
||||
);
|
||||
const currentHost = reconnectHost(
|
||||
store,
|
||||
room,
|
||||
"host_session_reconnected_12345678",
|
||||
);
|
||||
complete(router, currentHost);
|
||||
router.setPaused(room.roomId, true);
|
||||
const snapshot = await router.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: currentHost.sessionId,
|
||||
generation: 1,
|
||||
videoCodec: "h264",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(snapshot).not.toBeNull();
|
||||
const target = snapshot!.targets.find(
|
||||
(candidate) => candidate.preparationBinding.kind === "sfu",
|
||||
)!;
|
||||
const prepared = preparedFor(sent, target.viewerSessionId)!;
|
||||
|
||||
router.handleRouteFailed(staleHost, {
|
||||
type: "route-failed",
|
||||
revision: prepared.revision,
|
||||
phase: "prepare",
|
||||
connectionId: null,
|
||||
});
|
||||
|
||||
expect(router.parkCodecSfuReplacement(room.roomId, 1)).toBe(true);
|
||||
router.abortCodecSfuReplacement(room.roomId, 1);
|
||||
} finally {
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("wakes codec revalidation when a paused active edge fails", async () => {
|
||||
const { store, sent, onActiveRouteChanged, router } = harness(1);
|
||||
const room = await store.createRoom();
|
||||
const host = connectHost(store, room);
|
||||
complete(router, host);
|
||||
const viewer = connectViewer(store, room, "paused_hard_failure");
|
||||
complete(router, viewer);
|
||||
await vi.waitFor(() =>
|
||||
expect(preparedFor(sent, viewer.sessionId)).toBeDefined(),
|
||||
);
|
||||
const prepared = preparedFor(sent, viewer.sessionId)!;
|
||||
router.handleRouteReady(viewer, {
|
||||
type: "route-ready",
|
||||
revision: prepared.revision,
|
||||
phase: "prepare",
|
||||
});
|
||||
router.setPaused(room.roomId, true);
|
||||
onActiveRouteChanged.mockClear();
|
||||
|
||||
router.handleRouteFailed(viewer, {
|
||||
type: "route-failed",
|
||||
revision: prepared.revision,
|
||||
phase: "active",
|
||||
connectionId: prepared.candidate.connectionId,
|
||||
});
|
||||
|
||||
expect(onActiveRouteChanged).toHaveBeenCalledTimes(1);
|
||||
expect(onActiveRouteChanged).toHaveBeenCalledWith(room.roomId);
|
||||
expect(router.codecRouteSnapshot(room.roomId)?.targets).toEqual([]);
|
||||
await router.close();
|
||||
});
|
||||
|
||||
it("clears an exact drain waiter on codec abort", async () => {
|
||||
const { store, sent, roomControl, router } = harness(1, true, undefined, 2);
|
||||
const drain = deferred();
|
||||
try {
|
||||
const room = await store.createRoom();
|
||||
const { host } = await establishSfuRoom(store, sent, router, room);
|
||||
roomControl!.deleteBarrier = drain.promise;
|
||||
router.setPaused(room.roomId, true);
|
||||
const preparation = router.beginCodecSfuReplacement({
|
||||
roomId: room.roomId,
|
||||
hostSessionId: host.sessionId,
|
||||
generation: 1,
|
||||
videoCodec: "h264",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
const waiters = Reflect.get(router, "resourceWaiters") as Map<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
await vi.waitFor(() => expect(waiters.has(room.roomId)).toBe(true));
|
||||
|
||||
router.abortCodecSfuReplacement(room.roomId, 1);
|
||||
await expect(preparation).resolves.toBeNull();
|
||||
expect(waiters.has(room.roomId)).toBe(false);
|
||||
} finally {
|
||||
drain.resolve();
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits typed exhaustion and clears diagnostics on departure and room deletion", async () => {
|
||||
const { store, sent, router } = harness(1);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
const offer: SignalPayload = {
|
||||
kind: "description",
|
||||
connectionId: "connection_12345678",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "test-offer" },
|
||||
};
|
||||
|
||||
|
||||
+47
-95
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
DEFAULT_VIEWER_DISPLAY_NAME,
|
||||
MAX_DISPLAY_NAME_CODE_POINTS,
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
@@ -66,7 +67,7 @@ const qualityEvidence = {
|
||||
} as const;
|
||||
|
||||
describe("client signaling protocol", () => {
|
||||
it("keeps executable senders outside the Browser-only v9 checkpoint", () => {
|
||||
it("keeps executable senders outside the Browser-only v10 checkpoint", () => {
|
||||
const nativeWire = readFileSync(
|
||||
join(
|
||||
import.meta.dirname,
|
||||
@@ -75,7 +76,7 @@ describe("client signaling protocol", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(SIGNALING_PROTOCOL).toBe("screener-v9");
|
||||
expect(SIGNALING_PROTOCOL).toBe("screener-v10");
|
||||
expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/);
|
||||
});
|
||||
|
||||
@@ -236,6 +237,24 @@ describe("client signaling protocol", () => {
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ viewerGrant });
|
||||
expect(
|
||||
decodeClientMessage(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId,
|
||||
role: "host",
|
||||
token,
|
||||
clientId: "host_client_12345678",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
qualitySettings: qualitySettingsWithCodec,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
role: "host",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
qualitySettings: qualitySettingsWithCodec,
|
||||
});
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
@@ -254,17 +273,15 @@ describe("client signaling protocol", () => {
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
for (const protocol of ["screener-v8", "screener-v3"]) {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol,
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol: "invalid-protocol",
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes display names and rejects misleading Unicode boundaries", () => {
|
||||
@@ -411,18 +428,6 @@ describe("client signaling protocol", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the previous signaling wire", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v5",
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a host token and rejects viewer tokens or malformed room codes", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
@@ -539,18 +544,24 @@ describe("client signaling protocol", () => {
|
||||
});
|
||||
|
||||
it("keeps intentional pause updates strict and generation-bound", () => {
|
||||
const update = {
|
||||
const pause = {
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
paused: true,
|
||||
};
|
||||
expect(clientMessageSchema.safeParse(update).success).toBe(true);
|
||||
expect(clientMessageSchema.safeParse(pause).success).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...update, shareGeneration: undefined })
|
||||
clientMessageSchema.safeParse({ ...pause, paused: false }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...pause, shareGeneration: undefined })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...update, paused: "true" }).success,
|
||||
clientMessageSchema.safeParse({ ...pause, paused: "true" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...pause, unexpected: 1 }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
@@ -563,79 +574,21 @@ describe("client signaling protocol", () => {
|
||||
serverMessageSchema.safeParse({ type: "host-status", online: true })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires one exact Resume attempt across authorization, ack, and proof", () => {
|
||||
const sourceAck = {
|
||||
type: "sharing-source-enabled",
|
||||
const authoritativePause = {
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
codecGeneration: 4,
|
||||
resumeAttempt: 9,
|
||||
};
|
||||
expect(clientMessageSchema.safeParse(sourceAck).success).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...sourceAck, resumeAttempt: undefined })
|
||||
.success,
|
||||
).toBe(false);
|
||||
|
||||
const proof = {
|
||||
type: "video-codec-proof",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
generation: 4,
|
||||
resumeAttempt: 9,
|
||||
routeRevision: 7,
|
||||
binding: { kind: "peer", connectionId: "connection_12345678" },
|
||||
evidence: {
|
||||
baselineSampleTimestampMs: 100,
|
||||
sampleTimestampMs: 200,
|
||||
rtpStatsId: "inbound_video_12345678",
|
||||
rtpSsrc: 42,
|
||||
rtpMid: "0",
|
||||
rtpRid: null,
|
||||
trackIdentifier: "track_12345678",
|
||||
framesDecodedDelta: 1,
|
||||
actualCodec: "h264",
|
||||
},
|
||||
};
|
||||
expect(clientMessageSchema.safeParse(proof).success).toBe(true);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({ ...proof, resumeAttempt: undefined })
|
||||
.success,
|
||||
).toBe(false);
|
||||
|
||||
const authorization = {
|
||||
type: "sharing-resume-authorized",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
codecGeneration: 4,
|
||||
resumeAttempt: 9,
|
||||
};
|
||||
expect(serverMessageSchema.safeParse(authorization).success).toBe(true);
|
||||
expect(serverMessageSchema.safeParse(authoritativePause).success).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...authorization,
|
||||
resumeAttempt: undefined,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
const proofRequest = {
|
||||
type: "video-codec-proof-request",
|
||||
shareGeneration: "share_generation_12345678",
|
||||
generation: 4,
|
||||
resumeAttempt: 9,
|
||||
routeRevision: 7,
|
||||
expectedCodec: "h264",
|
||||
binding: { kind: "peer", connectionId: "connection_12345678" },
|
||||
};
|
||||
expect(serverMessageSchema.safeParse(proofRequest).success).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...proofRequest,
|
||||
resumeAttempt: undefined,
|
||||
...authoritativePause,
|
||||
unexpected: null,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only strict, bounded quality settings", () => {
|
||||
expect(DEFAULT_QUALITY_SETTINGS.videoCodec).toBe("vp8");
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-quality-settings",
|
||||
@@ -1314,7 +1267,6 @@ describe("server signaling protocol", () => {
|
||||
childPeerId: "child_12345678",
|
||||
connectionId: "connection_12345678",
|
||||
transport: "direct",
|
||||
codecTransition: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
@@ -1328,7 +1280,6 @@ describe("server signaling protocol", () => {
|
||||
childPeerId: "child_12345678",
|
||||
connectionId: "connection_12345678",
|
||||
transport: "sfu",
|
||||
codecTransition: { generation: 4, videoCodec: "h264" },
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
@@ -1342,6 +1293,7 @@ describe("server signaling protocol", () => {
|
||||
childPeerId: "child_12345678",
|
||||
connectionId: "connection_12345678",
|
||||
transport: "direct",
|
||||
unexpected: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
@@ -51,7 +51,13 @@ describe("realtime quality controls", () => {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
videoCodec: "vp8",
|
||||
});
|
||||
expect(
|
||||
Object.values(QUALITY_PROFILES).every(
|
||||
(profile) => profile.videoCodec === "vp8",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("offers 854x480 only through advanced resolution settings", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type CandidateCursorGuard,
|
||||
type CandidateReservation,
|
||||
type CommittedEdgeSeed,
|
||||
type EdgeGuard,
|
||||
type OperationSnapshot,
|
||||
} from "../src/server/room-route-controller.ts";
|
||||
|
||||
@@ -53,7 +52,6 @@ function addViewer(
|
||||
|
||||
function cursorGuard(operation: OperationSnapshot): CandidateCursorGuard {
|
||||
return {
|
||||
owner: { ...operation.owner },
|
||||
childPeerId: operation.childPeerId,
|
||||
childSessionId: operation.childSessionId,
|
||||
baseRevision: operation.baseRevision,
|
||||
@@ -126,26 +124,6 @@ function peerEdge(
|
||||
};
|
||||
}
|
||||
|
||||
function beginCodecSfu(
|
||||
routes: RoomRouteController<string>,
|
||||
overrides: Partial<
|
||||
Parameters<typeof routes.beginCodecSfuPublication>[0]
|
||||
> = {},
|
||||
) {
|
||||
return routes.beginCodecSfuPublication({
|
||||
routeRevision: routes.snapshot().revision,
|
||||
hostSessionId: "host_session",
|
||||
codecGeneration: 1,
|
||||
videoCodec: "h264",
|
||||
publicationGeneration: "publication_fresh",
|
||||
connectionId: "subscription_fresh",
|
||||
publicationConnectionId: "publication_connection_fresh",
|
||||
nowMs: 10,
|
||||
deadlineAtMs: 110,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RoomRouteController", () => {
|
||||
it("retains one latest timing sample for a 20-Viewer burst", () => {
|
||||
const routes = controller(2);
|
||||
@@ -1139,621 +1117,6 @@ describe("RoomRouteController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts one codec-owned SFU gap with a deterministic anchor and retained subtrees", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, B, 1);
|
||||
addViewer(routes, A, 0);
|
||||
addViewer(routes, C, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
for (const peerId of [B, A]) {
|
||||
routes.hydrateEdge(peerId, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: `${peerId}_sfu_old`,
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: `${peerId}_subscription_old`,
|
||||
});
|
||||
}
|
||||
routes.hydrateEdge(C, peerEdge(B, "c_from_b"));
|
||||
routes.setPaused(true);
|
||||
|
||||
const begun = beginCodecSfu(routes);
|
||||
expect(begun).toMatchObject({
|
||||
accepted: true,
|
||||
activeRevision: 1,
|
||||
anchorPeerId: B,
|
||||
retiredPeerIds: [B, A],
|
||||
operation: {
|
||||
owner: {
|
||||
kind: "codec",
|
||||
codecGeneration: 1,
|
||||
phase: "preparing",
|
||||
videoCodec: "h264",
|
||||
publicationGeneration: "publication_fresh",
|
||||
connectionId: "subscription_fresh",
|
||||
publicationConnectionId: "publication_connection_fresh",
|
||||
},
|
||||
childPeerId: B,
|
||||
candidates: [{ tuple: { kind: "sfu", publication: "replace" } }],
|
||||
},
|
||||
});
|
||||
expect(begun.released).toEqual(expect.arrayContaining([
|
||||
"publication_resource_old",
|
||||
`${B}_subscription_old`,
|
||||
`${A}_subscription_old`,
|
||||
]));
|
||||
expect(begun.released).toHaveLength(3);
|
||||
expect(routes.snapshot().hostPublication).toMatchObject({
|
||||
generation: "publication_old",
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
for (const peerId of [B, A]) {
|
||||
expect(routes.snapshot().upstreamByViewer.get(peerId)).toMatchObject({
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
}
|
||||
expect(routes.snapshot().upstreamByViewer.get(C)).toMatchObject({
|
||||
kind: "peer",
|
||||
parentPeerId: B,
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
});
|
||||
expect(beginCodecSfu(routes, { codecGeneration: 2 }).accepted).toBe(false);
|
||||
});
|
||||
|
||||
it("parks an exact codec SFU attempt until proving and rebinds logical subscribers on commit", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, A, 0);
|
||||
addViewer(routes, B, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
for (const peerId of [A, B]) {
|
||||
routes.hydrateEdge(peerId, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: `${peerId}_sfu_old`,
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: `${peerId}_subscription_old`,
|
||||
});
|
||||
}
|
||||
routes.setPaused(true);
|
||||
const begun = beginCodecSfu(routes);
|
||||
const guard = begun.guard!;
|
||||
|
||||
const wrongIdentity = beginCandidate(routes, {
|
||||
nowMs: 11,
|
||||
connectionId: "wrong_subscription",
|
||||
publicationGeneration: "publication_fresh",
|
||||
publicationConnectionId: "publication_connection_fresh",
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "wrong_edge_reservation",
|
||||
publication: "wrong_publication_reservation",
|
||||
},
|
||||
});
|
||||
expect(wrongIdentity.accepted).toBe(false);
|
||||
expect(wrongIdentity.released).toEqual([
|
||||
"wrong_edge_reservation",
|
||||
"wrong_publication_reservation",
|
||||
]);
|
||||
|
||||
const pending = beginCandidate(routes, {
|
||||
nowMs: 12,
|
||||
connectionId: guard.connectionId,
|
||||
publicationGeneration: guard.publicationGeneration,
|
||||
publicationConnectionId: guard.publicationConnectionId,
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "subscription_resource_fresh",
|
||||
publication: "publication_resource_fresh",
|
||||
},
|
||||
}).operation!;
|
||||
const readyGuard: CandidateGuard = {
|
||||
childPeerId: pending.childPeerId,
|
||||
childSessionId: pending.childSessionId,
|
||||
revision: pending.current!.revision,
|
||||
connectionId: guard.connectionId,
|
||||
};
|
||||
expect(routes.candidateReady(readyGuard, 13).accepted).toBe(false);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "preparing",
|
||||
phase: "prepared",
|
||||
}).accepted).toBe(true);
|
||||
expect(routes.operationExpired(10_000).accepted).toBe(false);
|
||||
expect(routes.snapshot().operation?.owner).toMatchObject({
|
||||
kind: "codec",
|
||||
phase: "prepared",
|
||||
});
|
||||
expect(routes.candidateReady(readyGuard, 10_001).accepted).toBe(false);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard: { ...guard, connectionId: "stale_connection" },
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 20,
|
||||
deadlineAtMs: 30,
|
||||
}).accepted).toBe(false);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 20,
|
||||
deadlineAtMs: 30,
|
||||
}).accepted).toBe(true);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "proving",
|
||||
phase: "prepared",
|
||||
}).accepted).toBe(true);
|
||||
expect(routes.operationExpired(1_000).accepted).toBe(false);
|
||||
expect(routes.candidateReady(readyGuard, 1_001).accepted).toBe(false);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 22,
|
||||
deadlineAtMs: 40,
|
||||
}).accepted).toBe(true);
|
||||
|
||||
expect(routes.candidateReady(readyGuard, 23).accepted).toBe(true);
|
||||
const committed = routes.snapshot();
|
||||
expect(committed.operation).toBeUndefined();
|
||||
expect(committed.hostPublication).toMatchObject({
|
||||
generation: "publication_fresh",
|
||||
connectionId: "publication_connection_fresh",
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: "publication_resource_fresh",
|
||||
});
|
||||
expect(committed.upstreamByViewer.get(A)).toMatchObject({
|
||||
publicationGeneration: "publication_fresh",
|
||||
connectionId: "subscription_fresh",
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: "subscription_resource_fresh",
|
||||
});
|
||||
expect(committed.upstreamByViewer.get(B)).toMatchObject({
|
||||
publicationGeneration: "publication_fresh",
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
resource: `${B}_subscription_old`,
|
||||
});
|
||||
});
|
||||
|
||||
it("folds an invalid direct binding into the pending codec revision", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, A, 1);
|
||||
addViewer(routes, B, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
routes.hydrateEdge(A, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: "a_sfu_old",
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: "a_subscription_old",
|
||||
});
|
||||
routes.hydrateEdge(B, peerEdge(A, "b_from_a"));
|
||||
routes.setPaused(true);
|
||||
const begun = beginCodecSfu(routes);
|
||||
const guard = begun.guard!;
|
||||
const pending = beginCandidate(routes, {
|
||||
nowMs: 11,
|
||||
connectionId: guard.connectionId,
|
||||
publicationGeneration: guard.publicationGeneration,
|
||||
publicationConnectionId: guard.publicationConnectionId,
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "subscription_resource_fresh",
|
||||
publication: "publication_resource_fresh",
|
||||
},
|
||||
}).operation!;
|
||||
const pendingRevision = pending.current!.revision;
|
||||
const activeRevision = routes.snapshot().revision;
|
||||
|
||||
expect(
|
||||
routes.retireCommittedTransportsForRouteWait(
|
||||
[
|
||||
{
|
||||
childPeerId: B,
|
||||
childSessionId: `${B}_session`,
|
||||
parentSessionId: `${A}_session`,
|
||||
routeRevision: activeRevision,
|
||||
connectionId: "b_from_a",
|
||||
},
|
||||
],
|
||||
12,
|
||||
),
|
||||
).toMatchObject({
|
||||
accepted: true,
|
||||
activeRevision,
|
||||
retiredPeerIds: [B],
|
||||
});
|
||||
expect(routes.snapshot().revision).toBe(activeRevision);
|
||||
expect(routes.snapshot().upstreamByViewer.get(B)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: true,
|
||||
});
|
||||
expect(routes.snapshot().operation?.current?.revision).toBe(pendingRevision);
|
||||
|
||||
expect(
|
||||
routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "preparing",
|
||||
phase: "prepared",
|
||||
}).accepted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 13,
|
||||
deadlineAtMs: 30,
|
||||
}).accepted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
routes.candidateReady(
|
||||
{
|
||||
childPeerId: A,
|
||||
childSessionId: `${A}_session`,
|
||||
revision: pendingRevision,
|
||||
connectionId: guard.connectionId,
|
||||
},
|
||||
14,
|
||||
).accepted,
|
||||
).toBe(true);
|
||||
expect(routes.snapshot().revision).toBe(pendingRevision);
|
||||
expect(routes.snapshot().upstreamByViewer.get(B)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["before-candidate", "commit"],
|
||||
["before-candidate", "abort"],
|
||||
["after-candidate", "commit"],
|
||||
["after-candidate", "abort"],
|
||||
] as const)(
|
||||
"retires a C=2 invalid exact edge folded %s through codec %s",
|
||||
(foldAt, outcome) => {
|
||||
const routes = controller(2, { sfuEnabled: true });
|
||||
addViewer(routes, A, 0);
|
||||
addViewer(routes, B, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
routes.hydrateEdge(A, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: "a_sfu_old",
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: "a_subscription_old",
|
||||
});
|
||||
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"));
|
||||
routes.setPaused(true);
|
||||
const begun = beginCodecSfu(routes);
|
||||
const guard = begun.guard!;
|
||||
const activeRevision = routes.snapshot().revision;
|
||||
const directGuard: EdgeGuard = {
|
||||
childPeerId: B,
|
||||
childSessionId: `${B}_session`,
|
||||
parentSessionId: "host_session",
|
||||
routeRevision: activeRevision,
|
||||
connectionId: "b_from_host",
|
||||
};
|
||||
const invalidateAndFold = (nowMs: number) => {
|
||||
expect(routes.invalidateEdge(directGuard, nowMs)).toBe(true);
|
||||
expect(routes.snapshot().upstreamByViewer.get(B)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: true,
|
||||
});
|
||||
const invalidatedFactVersion = routes.snapshot().factVersion;
|
||||
expect(
|
||||
routes.retireCommittedTransportsForRouteWait(
|
||||
[directGuard],
|
||||
nowMs + 1,
|
||||
),
|
||||
).toMatchObject({
|
||||
accepted: true,
|
||||
activeRevision,
|
||||
released: [],
|
||||
});
|
||||
expect(routes.snapshot().factVersion).toBe(invalidatedFactVersion);
|
||||
expect(routes.invalidateEdge(directGuard, nowMs + 2)).toBe(true);
|
||||
expect(
|
||||
routes.retireCommittedTransportsForRouteWait(
|
||||
[directGuard],
|
||||
nowMs + 3,
|
||||
),
|
||||
).toMatchObject({
|
||||
accepted: true,
|
||||
activeRevision,
|
||||
released: [],
|
||||
});
|
||||
expect(routes.snapshot().factVersion).toBe(invalidatedFactVersion);
|
||||
expect(routes.snapshot().upstreamByViewer.get(B)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (foldAt === "before-candidate") invalidateAndFold(11);
|
||||
const pending = beginCandidate(routes, {
|
||||
nowMs: 20,
|
||||
connectionId: guard.connectionId,
|
||||
publicationGeneration: guard.publicationGeneration,
|
||||
publicationConnectionId: guard.publicationConnectionId,
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "subscription_resource_fresh",
|
||||
publication: "publication_resource_fresh",
|
||||
},
|
||||
}).operation!;
|
||||
if (foldAt === "after-candidate") invalidateAndFold(21);
|
||||
expect(
|
||||
routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "preparing",
|
||||
phase: "prepared",
|
||||
}).accepted,
|
||||
).toBe(true);
|
||||
expect(routes.operationExpired(1_000_000).accepted).toBe(false);
|
||||
|
||||
if (outcome === "commit") {
|
||||
expect(
|
||||
routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 30,
|
||||
deadlineAtMs: 40,
|
||||
}).accepted,
|
||||
).toBe(true);
|
||||
const settled = routes.candidateReady(
|
||||
{
|
||||
childPeerId: A,
|
||||
childSessionId: `${A}_session`,
|
||||
revision: pending.current!.revision,
|
||||
connectionId: guard.connectionId,
|
||||
},
|
||||
31,
|
||||
);
|
||||
expect(settled.accepted).toBe(true);
|
||||
expect(settled.activeRevision).toBe(pending.current!.revision);
|
||||
} else {
|
||||
const aborted = routes.abortCodecOperation(guard, 31);
|
||||
expect(aborted.accepted).toBe(true);
|
||||
expect(aborted.activeRevision).toBeGreaterThan(
|
||||
pending.current!.revision,
|
||||
);
|
||||
expect(aborted.released).toEqual([
|
||||
"subscription_resource_fresh",
|
||||
"publication_resource_fresh",
|
||||
]);
|
||||
}
|
||||
expect(routes.snapshot().upstreamByViewer.get(B)).toMatchObject({
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
expect(
|
||||
routes.retireCommittedTransportsForRouteWait([directGuard], 32),
|
||||
).toMatchObject({ accepted: false, released: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts an exact pending codec publication and starts a fresh retained-anchor rollback", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, A, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
routes.hydrateEdge(A, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: "subscription_old",
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: "subscription_resource_old",
|
||||
});
|
||||
routes.setPaused(true);
|
||||
const forward = beginCodecSfu(routes);
|
||||
const forwardGuard = forward.guard!;
|
||||
const pending = beginCandidate(routes, {
|
||||
nowMs: 11,
|
||||
connectionId: forwardGuard.connectionId,
|
||||
publicationGeneration: forwardGuard.publicationGeneration,
|
||||
publicationConnectionId: forwardGuard.publicationConnectionId,
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "forward_subscription_resource",
|
||||
publication: "forward_publication_resource",
|
||||
},
|
||||
}).operation!;
|
||||
const pendingRevision = pending.current!.revision;
|
||||
|
||||
expect(routes.abortCodecOperation({
|
||||
...forwardGuard,
|
||||
codecGeneration: 2,
|
||||
}, 12).accepted).toBe(false);
|
||||
const aborted = routes.abortCodecOperation(forwardGuard, 12);
|
||||
expect(aborted.accepted).toBe(true);
|
||||
expect(aborted.activeRevision).toBeGreaterThan(pendingRevision);
|
||||
expect(aborted.released).toEqual([
|
||||
"forward_subscription_resource",
|
||||
"forward_publication_resource",
|
||||
]);
|
||||
expect(routes.snapshot().operation).toBeUndefined();
|
||||
expect(routes.snapshot().upstreamByViewer.get(A)).toMatchObject({
|
||||
publicationGeneration: "publication_old",
|
||||
usable: false,
|
||||
physicalActive: false,
|
||||
});
|
||||
|
||||
const rollback = beginCodecSfu(routes, {
|
||||
routeRevision: routes.snapshot().revision,
|
||||
codecGeneration: 2,
|
||||
videoCodec: "automatic",
|
||||
publicationGeneration: "publication_rollback",
|
||||
connectionId: "subscription_rollback",
|
||||
publicationConnectionId: "publication_connection_rollback",
|
||||
nowMs: 20,
|
||||
deadlineAtMs: 120,
|
||||
retainedAnchor: {
|
||||
childPeerId: A,
|
||||
childSessionId: `${A}_session`,
|
||||
},
|
||||
});
|
||||
expect(rollback).toMatchObject({
|
||||
accepted: true,
|
||||
anchorPeerId: A,
|
||||
retiredPeerIds: [],
|
||||
released: [],
|
||||
operation: {
|
||||
owner: {
|
||||
kind: "codec",
|
||||
codecGeneration: 2,
|
||||
phase: "preparing",
|
||||
videoCodec: "automatic",
|
||||
publicationGeneration: "publication_rollback",
|
||||
},
|
||||
candidates: [{ tuple: { kind: "sfu", publication: "replace" } }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["preparing", "proving"] as const)(
|
||||
"reselects a connected logical SFU placeholder when the %s anchor leaves",
|
||||
(phase) => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, A, 0);
|
||||
addViewer(routes, B, 0);
|
||||
routes.hydrateHostPublication(
|
||||
"publication_old",
|
||||
"publication_resource_old",
|
||||
"publication_connection_old",
|
||||
);
|
||||
for (const peerId of [A, B]) {
|
||||
routes.hydrateEdge(peerId, {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "publication_old",
|
||||
transport: "sfu",
|
||||
connectionId: `${peerId}_sfu_old`,
|
||||
usable: true,
|
||||
physicalActive: true,
|
||||
resource: `${peerId}_subscription_old`,
|
||||
});
|
||||
}
|
||||
routes.setPaused(true);
|
||||
const forward = beginCodecSfu(routes);
|
||||
const guard = forward.guard!;
|
||||
beginCandidate(routes, {
|
||||
nowMs: 11,
|
||||
connectionId: guard.connectionId,
|
||||
publicationGeneration: guard.publicationGeneration,
|
||||
publicationConnectionId: guard.publicationConnectionId,
|
||||
reservation: {
|
||||
kind: "sfu-create",
|
||||
edge: "forward_subscription_resource",
|
||||
publication: "forward_publication_resource",
|
||||
},
|
||||
});
|
||||
if (phase === "proving") {
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "preparing",
|
||||
phase: "prepared",
|
||||
}).accepted).toBe(true);
|
||||
expect(routes.transitionCodecOperation({
|
||||
guard,
|
||||
expectedPhase: "prepared",
|
||||
phase: "proving",
|
||||
nowMs: 12,
|
||||
deadlineAtMs: 112,
|
||||
}).accepted).toBe(true);
|
||||
}
|
||||
|
||||
expect(routes.disconnectSession(A, `${A}_session`)).toBe(true);
|
||||
expect(routes.confirmDeparture(A, 13)).toBe(true);
|
||||
expect(routes.abortCodecOperation(guard, 14).accepted).toBe(true);
|
||||
const rollback = beginCodecSfu(routes, {
|
||||
routeRevision: routes.snapshot().revision,
|
||||
codecGeneration: 2,
|
||||
videoCodec: "automatic",
|
||||
publicationGeneration: `publication_rollback_${phase}`,
|
||||
connectionId: `subscription_rollback_${phase}`,
|
||||
publicationConnectionId: `publication_connection_rollback_${phase}`,
|
||||
nowMs: 20,
|
||||
deadlineAtMs: 120,
|
||||
});
|
||||
expect(rollback).toMatchObject({
|
||||
accepted: true,
|
||||
anchorPeerId: B,
|
||||
operation: {
|
||||
owner: {
|
||||
kind: "codec",
|
||||
codecGeneration: 2,
|
||||
phase: "preparing",
|
||||
},
|
||||
childPeerId: B,
|
||||
candidates: [
|
||||
{ tuple: { kind: "sfu", publication: "replace" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("creates a codec SFU publication for an exact retained waiting anchor", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
addViewer(routes, A, 0);
|
||||
routes.setPaused(true);
|
||||
const begun = beginCodecSfu(routes, {
|
||||
retainedAnchor: {
|
||||
childPeerId: A,
|
||||
childSessionId: `${A}_session`,
|
||||
},
|
||||
});
|
||||
expect(begun).toMatchObject({
|
||||
accepted: true,
|
||||
anchorPeerId: A,
|
||||
activeRevision: 0,
|
||||
retiredPeerIds: [],
|
||||
released: [],
|
||||
operation: {
|
||||
owner: { kind: "codec", phase: "preparing" },
|
||||
candidates: [{ tuple: { kind: "sfu", publication: "create" } }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces one publication and makes every old subscriber waiting", () => {
|
||||
const routes = controller(1, { sfuEnabled: true });
|
||||
|
||||
+26
-375
@@ -82,12 +82,10 @@ const candidate = (
|
||||
childPeerId = "viewer_12345678",
|
||||
transport: "direct" | "sfu" = "direct",
|
||||
connectionId = `candidate_${revision}_12345678`,
|
||||
codecTransition: PreparedRouteCandidate["codecTransition"] = null,
|
||||
): PreparedRouteCandidate => ({
|
||||
childPeerId,
|
||||
connectionId,
|
||||
transport,
|
||||
codecTransition,
|
||||
});
|
||||
|
||||
function createFakeSubscriber(events: SubscriberEvents, log: string[], label: string) {
|
||||
@@ -144,37 +142,6 @@ describe("minimal route transition contracts", () => {
|
||||
).toBe("stale");
|
||||
});
|
||||
|
||||
it("treats the codec transaction tag as part of exact candidate identity", () => {
|
||||
const route = new MediaRouteTransition();
|
||||
const assignment = viewerSfuAssignment();
|
||||
const tagged = candidate(
|
||||
2,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_2_12345678",
|
||||
{ generation: 7, videoCodec: "h264" },
|
||||
);
|
||||
|
||||
expect(
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment,
|
||||
candidate: tagged,
|
||||
}),
|
||||
).toBe("accepted");
|
||||
expect(
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment,
|
||||
candidate: {
|
||||
...tagged,
|
||||
codecTransition: { generation: 8, videoCodec: "h264" },
|
||||
},
|
||||
}),
|
||||
).toBe("stale");
|
||||
});
|
||||
|
||||
it("keeps duplicate peer prepare idempotent and releases it before takeover", async () => {
|
||||
const prepared: Array<{ parentPeerId: string | null; revision?: number }> = [];
|
||||
@@ -336,143 +303,21 @@ describe("minimal route transition contracts", () => {
|
||||
expect(publishers[1]?.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prepares a fresh tagged SFU publisher while paused with the tagged codec", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const prepared: unknown[] = [];
|
||||
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
||||
const route = new HostSfuRoute({
|
||||
getStream: () => ({}) as MediaStream,
|
||||
getProfile: () => QUALITY_PROFILES["720p30"],
|
||||
reconcileChildren: () => undefined,
|
||||
send: (message) => {
|
||||
messages.push(message);
|
||||
return true;
|
||||
},
|
||||
onCodecPublisherPrepared: (result) => prepared.push(result),
|
||||
createPublisher: () => {
|
||||
const publisher = createFakePublisher([], "codec-publisher");
|
||||
publishers.push(publisher);
|
||||
return publisher;
|
||||
},
|
||||
});
|
||||
|
||||
route.setPaused(true);
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("ordinary-publication"),
|
||||
candidate: candidate(2, "viewer_12345678", "sfu"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
expect(publishers).toEqual([]);
|
||||
|
||||
route.accept({
|
||||
revision: 3,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("fresh-publication"),
|
||||
candidate: candidate(
|
||||
3,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_3_12345678",
|
||||
{ generation: 11, videoCodec: "h264" },
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(3));
|
||||
|
||||
expect(publishers).toHaveLength(1);
|
||||
expect(publishers[0]?.activate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ ...QUALITY_PROFILES["720p30"], videoCodec: "h264" },
|
||||
);
|
||||
expect(prepared).toEqual([
|
||||
{
|
||||
generation: 11,
|
||||
videoCodec: "h264",
|
||||
binding: {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "fresh-publication",
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
]);
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an exact rejected binding when fresh codec publication fails", async () => {
|
||||
const prepared: unknown[] = [];
|
||||
const route = new HostSfuRoute({
|
||||
getStream: () => ({}) as MediaStream,
|
||||
getProfile: () => QUALITY_PROFILES["720p30"],
|
||||
reconcileChildren: () => undefined,
|
||||
send: () => true,
|
||||
onCodecPublisherPrepared: (result) => prepared.push(result),
|
||||
createPublisher: () => ({
|
||||
...createFakePublisher([], "failed-codec-publisher"),
|
||||
activate: vi.fn(async () => false),
|
||||
}),
|
||||
});
|
||||
|
||||
route.setPaused(true);
|
||||
route.accept({
|
||||
revision: 5,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("failed-publication"),
|
||||
candidate: candidate(
|
||||
5,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_5_12345678",
|
||||
{ generation: 13, videoCodec: "vp8" },
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(5));
|
||||
|
||||
expect(prepared).toEqual([
|
||||
{
|
||||
generation: 13,
|
||||
videoCodec: "vp8",
|
||||
binding: {
|
||||
kind: "sfu",
|
||||
publicationGeneration: "failed-publication",
|
||||
},
|
||||
accepted: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reconciles paused Host active truth by exact SFU publication", async () => {
|
||||
let releaseOldPublisher!: () => void;
|
||||
const oldPublisherRetired = new Promise<void>((resolve) => {
|
||||
releaseOldPublisher = resolve;
|
||||
});
|
||||
const reconciledChildren: string[][] = [];
|
||||
const disconnectedCallbacks: Array<() => void> = [];
|
||||
const messages: ClientMessage[] = [];
|
||||
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
||||
const route = new HostSfuRoute({
|
||||
getStream: () => ({}) as MediaStream,
|
||||
getProfile: () => QUALITY_PROFILES["720p30"],
|
||||
reconcileChildren: (children) => reconciledChildren.push(children),
|
||||
send: (message) => {
|
||||
messages.push(message);
|
||||
return true;
|
||||
},
|
||||
createPublisher: (onDisconnected) => {
|
||||
disconnectedCallbacks.push(onDisconnected);
|
||||
send: () => true,
|
||||
createPublisher: () => {
|
||||
const publisher = createFakePublisher(
|
||||
[],
|
||||
`publisher-${publishers.length + 1}`,
|
||||
);
|
||||
if (publishers.length === 0) {
|
||||
publisher.deactivate.mockImplementation(async () => {
|
||||
await oldPublisherRetired;
|
||||
return true;
|
||||
});
|
||||
publisher.disconnect.mockImplementation(async () => {
|
||||
onDisconnected();
|
||||
});
|
||||
}
|
||||
publishers.push(publisher);
|
||||
return publisher;
|
||||
},
|
||||
@@ -500,53 +345,21 @@ describe("minimal route transition contracts", () => {
|
||||
assignment: hostAssignment("publication-old", ["retained-child"]),
|
||||
}),
|
||||
).toBe("duplicate");
|
||||
expect(publishers[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
const retiringOldPublication = route.acceptAndWait({
|
||||
await route.acceptAndWait({
|
||||
revision: 3,
|
||||
phase: "active",
|
||||
assignment: hostAssignment(null, ["retained-child"]),
|
||||
});
|
||||
disconnectedCallbacks[0]?.();
|
||||
|
||||
route.accept({
|
||||
revision: 4,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("publication-fresh", ["retained-child"]),
|
||||
candidate: candidate(
|
||||
4,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_4_12345678",
|
||||
{ generation: 15, videoCodec: "vp8" },
|
||||
),
|
||||
});
|
||||
const freshPublication = route.acceptConfig(sfuConfig(4));
|
||||
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
||||
expect(publishers[1]?.connect).toHaveBeenCalledOnce();
|
||||
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
||||
releaseOldPublisher();
|
||||
await Promise.all([retiringOldPublication, freshPublication]);
|
||||
expect(publishers[0]?.deactivate).toHaveBeenCalledOnce();
|
||||
expect(publishers[0]?.disconnect).toHaveBeenCalledOnce();
|
||||
expect(publishers[1]?.activate).toHaveBeenCalledOnce();
|
||||
disconnectedCallbacks[0]?.();
|
||||
expect(
|
||||
messages.filter((message) => message.type === "route-failed"),
|
||||
).toEqual([]);
|
||||
|
||||
route.setPaused(false);
|
||||
await route.acceptAndWait({
|
||||
revision: 4,
|
||||
phase: "active",
|
||||
assignment: hostAssignment("publication-fresh", ["retained-child"]),
|
||||
});
|
||||
expect(publishers[1]?.disconnect).not.toHaveBeenCalled();
|
||||
expect(reconciledChildren.at(-1)).toEqual(["retained-child"]);
|
||||
});
|
||||
|
||||
it("does not activate a fresh SFU publisher after its exact route becomes stale", async () => {
|
||||
let releaseOldPublisher!: () => void;
|
||||
const oldPublisherRetired = new Promise<void>((resolve) => {
|
||||
releaseOldPublisher = resolve;
|
||||
let releaseFreshPublisher!: () => void;
|
||||
const freshPublisherGate = new Promise<void>((resolve) => {
|
||||
releaseFreshPublisher = resolve;
|
||||
});
|
||||
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
||||
const route = new HostSfuRoute({
|
||||
@@ -559,9 +372,9 @@ describe("minimal route transition contracts", () => {
|
||||
[],
|
||||
`publisher-${publishers.length + 1}`,
|
||||
);
|
||||
if (publishers.length === 0) {
|
||||
publisher.deactivate.mockImplementation(async () => {
|
||||
await oldPublisherRetired;
|
||||
if (publishers.length === 1) {
|
||||
publisher.activate.mockImplementation(async () => {
|
||||
await freshPublisherGate;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -576,106 +389,36 @@ describe("minimal route transition contracts", () => {
|
||||
assignment: hostAssignment("publication-old"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(1));
|
||||
route.setPaused(true);
|
||||
const retiringOldPublication = route.acceptAndWait({
|
||||
revision: 2,
|
||||
phase: "active",
|
||||
assignment: hostAssignment(null),
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(publishers[0]?.deactivate).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
route.accept({
|
||||
revision: 3,
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("publication-fresh"),
|
||||
candidate: candidate(
|
||||
3,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_3_12345678",
|
||||
{ generation: 17, videoCodec: "h264" },
|
||||
),
|
||||
});
|
||||
const freshPublication = route.acceptConfig(sfuConfig(3));
|
||||
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
||||
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
||||
|
||||
const stalePublication = route.acceptAndWait({
|
||||
revision: 4,
|
||||
phase: "active",
|
||||
assignment: hostAssignment(null),
|
||||
});
|
||||
releaseOldPublisher();
|
||||
await Promise.all([
|
||||
retiringOldPublication,
|
||||
freshPublication,
|
||||
stalePublication,
|
||||
]);
|
||||
|
||||
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
||||
expect(publishers[1]?.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a tagged codec candidate that reuses the active SFU publication", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const prepared: unknown[] = [];
|
||||
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
||||
const route = new HostSfuRoute({
|
||||
getStream: () => ({}) as MediaStream,
|
||||
getProfile: () => QUALITY_PROFILES["720p30"],
|
||||
reconcileChildren: () => undefined,
|
||||
send: (message) => {
|
||||
messages.push(message);
|
||||
return true;
|
||||
},
|
||||
onCodecPublisherPrepared: (result) => prepared.push(result),
|
||||
createPublisher: () => {
|
||||
const publisher = createFakePublisher([], "publisher");
|
||||
publishers.push(publisher);
|
||||
return publisher;
|
||||
},
|
||||
});
|
||||
|
||||
await route.acceptAndWait({
|
||||
revision: 1,
|
||||
phase: "active",
|
||||
assignment: hostAssignment("publication-a"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(1));
|
||||
route.setPaused(true);
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
assignment: hostAssignment("publication-a"),
|
||||
candidate: candidate(
|
||||
2,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_2_12345678",
|
||||
{ generation: 14, videoCodec: "h264" },
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(2));
|
||||
const freshPublication = route.acceptConfig(sfuConfig(2));
|
||||
await vi.waitFor(() =>
|
||||
expect(publishers[1]?.activate).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
expect(publishers).toHaveLength(1);
|
||||
expect(prepared).toEqual([
|
||||
{
|
||||
generation: 14,
|
||||
videoCodec: "h264",
|
||||
binding: { kind: "sfu", publicationGeneration: "publication-a" },
|
||||
accepted: false,
|
||||
},
|
||||
]);
|
||||
expect(messages.at(-1)).toEqual({
|
||||
type: "route-failed",
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
connectionId: null,
|
||||
const staleRoute = route.acceptAndWait({
|
||||
revision: 3,
|
||||
phase: "active",
|
||||
assignment: hostAssignment("publication-old"),
|
||||
});
|
||||
releaseFreshPublisher();
|
||||
await Promise.all([freshPublication, staleRoute]);
|
||||
|
||||
expect(publishers[0]?.disconnect).not.toHaveBeenCalled();
|
||||
expect(publishers[1]?.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it("uses the subscriber first decoded frame as the only SFU prepare ready", async () => {
|
||||
const log: string[] = [];
|
||||
const messages: ClientMessage[] = [];
|
||||
@@ -774,66 +517,6 @@ describe("minimal route transition contracts", () => {
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a tagged SFU subscriber while paused and waits for a post-resume frame", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => true,
|
||||
reconcileSfuChildren: () => undefined,
|
||||
onSfuStream: () => undefined,
|
||||
send: (message) => {
|
||||
messages.push(message);
|
||||
return true;
|
||||
},
|
||||
createSubscriber: (events) => {
|
||||
subscriber = createFakeSubscriber(events, [], "codec-subscriber");
|
||||
return subscriber;
|
||||
},
|
||||
});
|
||||
|
||||
route.setPaused(true);
|
||||
route.accept({
|
||||
revision: 4,
|
||||
phase: "prepare",
|
||||
assignment: viewerSfuAssignment([], "fresh-publication"),
|
||||
candidate: candidate(
|
||||
4,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_4_12345678",
|
||||
{ generation: 12, videoCodec: "vp8" },
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(4));
|
||||
expect(subscriber.connect).toHaveBeenCalledOnce();
|
||||
expect(subscriber.activate).toHaveBeenCalledOnce();
|
||||
expect(subscriber.armDecodedFrameProof).not.toHaveBeenCalled();
|
||||
|
||||
subscriber.events.onStream({} as MediaStream);
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
|
||||
|
||||
route.setPaused(false);
|
||||
expect(subscriber.armDecodedFrameProof).toHaveBeenLastCalledWith(true);
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([
|
||||
{ type: "route-ready", revision: 4, phase: "prepare" },
|
||||
]);
|
||||
|
||||
route.setPaused(true);
|
||||
expect(subscriber.stopDecodedFrameProof).toHaveBeenCalled();
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
route.setPaused(false);
|
||||
expect(subscriber.armDecodedFrameProof).toHaveBeenLastCalledWith(true);
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([
|
||||
{ type: "route-ready", revision: 4, phase: "prepare" },
|
||||
]);
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([
|
||||
{ type: "route-ready", revision: 4, phase: "prepare" },
|
||||
{ type: "route-ready", revision: 4, phase: "prepare" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retires only an unreferenced paused Viewer SFU slot", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
@@ -910,37 +593,5 @@ describe("minimal route transition contracts", () => {
|
||||
expect(activatedPeers.at(-1)).toEqual(
|
||||
peerAssignment("retained-parent", ["retained-child"]),
|
||||
);
|
||||
|
||||
route.accept({
|
||||
revision: 4,
|
||||
phase: "prepare",
|
||||
assignment: viewerSfuAssignment(
|
||||
["retained-child"],
|
||||
"publication-fresh",
|
||||
),
|
||||
candidate: candidate(
|
||||
4,
|
||||
"viewer_12345678",
|
||||
"sfu",
|
||||
"candidate_4_12345678",
|
||||
{ generation: 16, videoCodec: "h264" },
|
||||
),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(4));
|
||||
subscribers[0]?.events.onDisconnected();
|
||||
subscribers[1]?.events.onStream({} as MediaStream);
|
||||
subscribers[1]?.events.onFirstDecodedFrame();
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
|
||||
expect(
|
||||
messages.filter((message) => message.type === "route-failed"),
|
||||
).toEqual([]);
|
||||
|
||||
route.setPaused(false);
|
||||
expect(subscribers[1]?.armDecodedFrameProof).toHaveBeenLastCalledWith(true);
|
||||
subscribers[1]?.events.onFirstDecodedFrame();
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([
|
||||
{ type: "route-ready", revision: 4, phase: "prepare" },
|
||||
]);
|
||||
expect(subscribers[1]?.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+177
-124
@@ -7,6 +7,7 @@ import {
|
||||
MAX_SIGNAL_BYTES,
|
||||
SIGNALING_PROTOCOL,
|
||||
decodeServerMessage,
|
||||
type QualitySettings,
|
||||
type Role,
|
||||
type ServerMessage,
|
||||
} from "../src/shared/protocol.ts";
|
||||
@@ -256,6 +257,7 @@ async function authenticate(
|
||||
viewerPassword?: string;
|
||||
codeOnly?: true;
|
||||
sharingPaused?: boolean;
|
||||
qualitySettings?: QualitySettings;
|
||||
} = {},
|
||||
) {
|
||||
client.socket.send(
|
||||
@@ -272,6 +274,9 @@ async function authenticate(
|
||||
...(presence.sharingPaused !== undefined
|
||||
? { sharingPaused: presence.sharingPaused }
|
||||
: {}),
|
||||
...(presence.qualitySettings
|
||||
? { qualitySettings: presence.qualitySettings }
|
||||
: {}),
|
||||
...(presence.viewerPresence ? { viewerPresence: true } : {}),
|
||||
...(presence.viewerPasswordSettings
|
||||
? { viewerPasswordSettings: true }
|
||||
@@ -1355,7 +1360,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "persistence-failure-edge",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -1495,31 +1499,11 @@ describe("WebSocket signaling", () => {
|
||||
|
||||
activeHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "request-sharing-resume",
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration,
|
||||
paused: false,
|
||||
}),
|
||||
);
|
||||
const resumeAuthorization = await activeHost.inbox.next(
|
||||
"sharing-resume-authorized",
|
||||
);
|
||||
expect(resumeAuthorization).toMatchObject({
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: expect.any(Number),
|
||||
});
|
||||
await replacement.inbox.expectNone(20);
|
||||
activeHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "sharing-source-enabled",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: resumeAuthorization.resumeAttempt,
|
||||
}),
|
||||
);
|
||||
expect(await activeHost.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
});
|
||||
expect(await replacement.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
@@ -1564,15 +1548,15 @@ describe("WebSocket signaling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ordinary P2P Resume paused until the exact source ack", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: false });
|
||||
const shareGeneration = "ordinary_resume_share_generation_12345678";
|
||||
it("applies symmetric pause updates only to the exact current Host share", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: true });
|
||||
const shareGeneration = "hybrid_pause_share_generation_12345678";
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
await authenticate(
|
||||
host,
|
||||
harness.room,
|
||||
"host",
|
||||
"ordinary-resume-host-client",
|
||||
"hybrid-pause-host-client",
|
||||
1,
|
||||
shareGeneration,
|
||||
);
|
||||
@@ -1581,81 +1565,46 @@ describe("WebSocket signaling", () => {
|
||||
viewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"ordinary-resume-viewer-client",
|
||||
"hybrid-pause-viewer-client",
|
||||
);
|
||||
await host.inbox.next("peer-joined");
|
||||
|
||||
const pause = {
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration,
|
||||
paused: true,
|
||||
};
|
||||
host.socket.send(JSON.stringify(pause));
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
paused: true,
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({ type: "request-sharing-resume", shareGeneration }),
|
||||
);
|
||||
const first = await host.inbox.next("sharing-resume-authorized");
|
||||
await viewer.inbox.expectNone(20);
|
||||
|
||||
host.socket.send(JSON.stringify(pause));
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
paused: true,
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({ type: "request-sharing-resume", shareGeneration }),
|
||||
);
|
||||
const second = await host.inbox.next("sharing-resume-authorized");
|
||||
expect(second.resumeAttempt).toBeGreaterThan(first.resumeAttempt);
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "sharing-source-enabled",
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: first.resumeAttempt,
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
expect(await host.inbox.next("pause-sharing-source")).toMatchObject({
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: first.resumeAttempt,
|
||||
});
|
||||
await viewer.inbox.expectNone(20);
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "sharing-source-enabled",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: second.resumeAttempt,
|
||||
}),
|
||||
);
|
||||
expect(await host.inbox.next("host-status")).toMatchObject({
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
paused: true,
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration,
|
||||
paused: false,
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
const closed = new Promise<number>((resolve) =>
|
||||
host.socket.once("close", (code) => resolve(code)),
|
||||
);
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "sharing-source-enabled",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: first.resumeAttempt,
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration: "stale_pause_share_generation_12345678",
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
await Promise.all([
|
||||
host.inbox.expectNone(20),
|
||||
viewer.inbox.expectNone(20),
|
||||
]);
|
||||
expect(await closed).toBe(4001);
|
||||
});
|
||||
|
||||
it("retains authoritative pause when a Resume ack is lost across Host reconnect", async () => {
|
||||
it("retains authoritative pause when a reconnecting Host advertises unpaused", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: false });
|
||||
const shareGeneration = "reconnect_resume_share_generation_12345678";
|
||||
const hostClientId = "reconnect-resume-host-client";
|
||||
@@ -1688,13 +1637,6 @@ describe("WebSocket signaling", () => {
|
||||
online: true,
|
||||
paused: true,
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({ type: "request-sharing-resume", shareGeneration }),
|
||||
);
|
||||
const lostAuthorization = await host.inbox.next(
|
||||
"sharing-resume-authorized",
|
||||
);
|
||||
|
||||
await closeClient(host);
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
online: false,
|
||||
@@ -1713,43 +1655,162 @@ describe("WebSocket signaling", () => {
|
||||
);
|
||||
expect(
|
||||
await reconnectedHost.inbox.next("pause-sharing-source"),
|
||||
).toMatchObject({
|
||||
).toEqual({
|
||||
type: "pause-sharing-source",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: null,
|
||||
});
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: true,
|
||||
});
|
||||
|
||||
reconnectedHost.socket.send(
|
||||
JSON.stringify({ type: "request-sharing-resume", shareGeneration }),
|
||||
);
|
||||
const retryAuthorization = await reconnectedHost.inbox.next(
|
||||
"sharing-resume-authorized",
|
||||
);
|
||||
expect(retryAuthorization.resumeAttempt).toBeGreaterThan(
|
||||
lostAuthorization.resumeAttempt,
|
||||
);
|
||||
reconnectedHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "sharing-source-enabled",
|
||||
type: "set-sharing-paused",
|
||||
shareGeneration,
|
||||
codecGeneration: null,
|
||||
resumeAttempt: retryAuthorization.resumeAttempt,
|
||||
paused: false,
|
||||
}),
|
||||
);
|
||||
expect(await reconnectedHost.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
});
|
||||
expect(await viewer.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
paused: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("binds the new share codec before routing and permits a new choice after stop", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: true });
|
||||
const h264Settings: QualitySettings = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "balanced",
|
||||
videoCodec: "h264",
|
||||
screenAudioQuality: "music",
|
||||
};
|
||||
const firstGeneration = "h264_share_generation_12345678";
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const viewerAuth = peerAssisted(
|
||||
await authenticate(
|
||||
viewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"codec-viewer-client",
|
||||
),
|
||||
);
|
||||
expect(viewerAuth.qualitySettings.videoCodec).toBe("vp8");
|
||||
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
const hostAuth = peerAssisted(
|
||||
await authenticate(
|
||||
host,
|
||||
harness.room,
|
||||
"host",
|
||||
"codec-host-client",
|
||||
1,
|
||||
firstGeneration,
|
||||
{ qualitySettings: h264Settings },
|
||||
),
|
||||
);
|
||||
expect(hostAuth.qualitySettings).toEqual(h264Settings);
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: h264Settings,
|
||||
});
|
||||
|
||||
const replaced = new Promise<number>((resolve) =>
|
||||
host.socket.once("close", (code) => resolve(code)),
|
||||
);
|
||||
const reconnectedHost = await openClient(harness.webSocketUrl);
|
||||
const reconnectedAuth = peerAssisted(
|
||||
await authenticate(
|
||||
reconnectedHost,
|
||||
harness.room,
|
||||
"host",
|
||||
"codec-host-client",
|
||||
1,
|
||||
firstGeneration,
|
||||
{
|
||||
qualitySettings: {
|
||||
...h264Settings,
|
||||
videoCodec: "vp8",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(await replaced).toBe(4001);
|
||||
expect(reconnectedAuth.qualitySettings).toEqual(h264Settings);
|
||||
await expect(
|
||||
viewer.inbox.next("quality-settings", 30),
|
||||
).rejects.toThrow("Timed out");
|
||||
|
||||
const updatedH264Settings: QualitySettings = {
|
||||
...h264Settings,
|
||||
resolution: "720p",
|
||||
maxBitrate: 3_000_000,
|
||||
};
|
||||
reconnectedHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: updatedH264Settings,
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: updatedH264Settings,
|
||||
});
|
||||
|
||||
reconnectedHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: {
|
||||
...updatedH264Settings,
|
||||
videoCodec: "vp8",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await reconnectedHost.inbox.next("error")).toEqual({
|
||||
type: "error",
|
||||
code: "FORBIDDEN",
|
||||
message: "视频编码只能在开始分享前选择;请停止分享后重新开始",
|
||||
});
|
||||
|
||||
const stopped = new Promise<number>((resolve) =>
|
||||
reconnectedHost.socket.once("close", (code) => resolve(code)),
|
||||
);
|
||||
reconnectedHost.socket.send(
|
||||
JSON.stringify({
|
||||
type: "stop-sharing",
|
||||
shareGeneration: firstGeneration,
|
||||
}),
|
||||
);
|
||||
expect(await stopped).toBe(1000);
|
||||
await viewer.inbox.next("sharing-stopped");
|
||||
|
||||
const vp8Settings: QualitySettings = {
|
||||
...updatedH264Settings,
|
||||
videoCodec: "vp8",
|
||||
};
|
||||
const nextHost = await openClient(harness.webSocketUrl);
|
||||
const nextAuth = peerAssisted(
|
||||
await authenticate(
|
||||
nextHost,
|
||||
harness.room,
|
||||
"host",
|
||||
"codec-host-client",
|
||||
1,
|
||||
"vp8_share_generation_12345678",
|
||||
{
|
||||
qualitySettings: vp8Settings,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(nextAuth.qualitySettings).toEqual(vp8Settings);
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: vp8Settings,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a viewer peer stable when it reconnects inside the grace period", async () => {
|
||||
const harness = await startHarness({ viewerDisconnectGraceMs: 80 });
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
@@ -1769,7 +1830,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "stable-connection",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -1883,7 +1943,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "waiting-host-offer",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -1910,7 +1969,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "waiting-viewer-answer",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "answer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -1945,7 +2003,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "active-host-offer",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -2049,7 +2106,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "promoted-host-offer",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -2081,7 +2137,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "connection-before-stop",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -2225,7 +2280,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "connection-before-control-outage",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -2335,7 +2389,6 @@ describe("WebSocket signaling", () => {
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "connection-during-grace",
|
||||
negotiationGeneration: null,
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}),
|
||||
@@ -2514,26 +2567,26 @@ describe("WebSocket signaling", () => {
|
||||
expect(await closeCode).toBe(1009);
|
||||
});
|
||||
|
||||
it("terminates an old client protocol before authentication", async () => {
|
||||
it("terminates an invalid protocol before authentication", async () => {
|
||||
const harness = await startHarness();
|
||||
const oldClient = await openClient(harness.webSocketUrl);
|
||||
const invalidClient = await openClient(harness.webSocketUrl);
|
||||
const closed = new Promise<{ code: number; reason: string }>((resolve) =>
|
||||
oldClient.socket.once("close", (code, reason) =>
|
||||
invalidClient.socket.once("close", (code, reason) =>
|
||||
resolve({ code, reason: reason.toString() }),
|
||||
),
|
||||
);
|
||||
|
||||
oldClient.socket.send(
|
||||
invalidClient.socket.send(
|
||||
JSON.stringify({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v5",
|
||||
protocol: "invalid-protocol",
|
||||
roomId: "999999999999",
|
||||
role: "viewer",
|
||||
clientId: "old-client",
|
||||
clientId: "invalid-client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await oldClient.inbox.next("error")).toMatchObject({
|
||||
expect(await invalidClient.inbox.next("error")).toMatchObject({
|
||||
code: "INVALID_MESSAGE",
|
||||
});
|
||||
expect(await closed).toEqual({ code: 1008, reason: "Invalid message" });
|
||||
|
||||
@@ -603,6 +603,7 @@ describe("SfuPublisher", () => {
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
|
||||
source: Track.Source.ScreenShare,
|
||||
backupCodec: false,
|
||||
videoCodec: "vp8",
|
||||
simulcast: true,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 8_000_000,
|
||||
@@ -622,7 +623,7 @@ describe("SfuPublisher", () => {
|
||||
});
|
||||
expect(
|
||||
room.localParticipant.publishTrack.mock.calls[0]?.[1],
|
||||
).not.toHaveProperty("videoCodec");
|
||||
).toHaveProperty("videoCodec", "vp8");
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
audioPreset: { maxBitrate: 128_000 },
|
||||
@@ -716,6 +717,22 @@ describe("SfuPublisher", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the SFU codec unset only for an explicit automatic preference", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
|
||||
await expect(
|
||||
publisher.activate(stream(track("video", "video-1")), {
|
||||
...qualityProfile,
|
||||
videoCodec: "automatic",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(
|
||||
livekit.state.rooms[0].localParticipant.publishTrack.mock.calls[0]?.[1],
|
||||
).not.toHaveProperty("videoCodec");
|
||||
});
|
||||
|
||||
it("fails closed when initial sender configuration is rejected", async () => {
|
||||
const disconnected = vi.fn();
|
||||
const publisher = new SfuPublisher({ onDisconnected: disconnected });
|
||||
|
||||
@@ -133,14 +133,10 @@ const timeoutCallbacks = new Map<number, () => void>();
|
||||
const timeoutDelays = new Map<number, number>();
|
||||
let nextTimeoutId = 1;
|
||||
|
||||
function offer(
|
||||
connectionId: string,
|
||||
negotiationGeneration: number | null = null,
|
||||
): SignalPayload {
|
||||
function offer(connectionId: string): SignalPayload {
|
||||
return {
|
||||
kind: "description",
|
||||
connectionId,
|
||||
negotiationGeneration,
|
||||
description: { type: "offer", sdp: `offer-${connectionId}` },
|
||||
};
|
||||
}
|
||||
@@ -666,7 +662,7 @@ describe("ViewerPeer connection generations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes same-connection codec offers and fences stale generations", async () => {
|
||||
it("serializes ordinary offers for the same connection", async () => {
|
||||
const firstLocalDescription = createDeferred<void>();
|
||||
FakePeerConnection.plans.push({
|
||||
localDescriptionGate: firstLocalDescription.promise,
|
||||
@@ -674,40 +670,23 @@ describe("ViewerPeer connection generations", () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = createPeer(signals, []);
|
||||
|
||||
const first = peer.acceptSignal("host", offer("codec-connection", 5));
|
||||
const first = peer.acceptSignal("host", offer("same-connection"));
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
FakePeerConnection.instances[0]?.setLocalDescription,
|
||||
).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const connection = FakePeerConnection.instances[0]!;
|
||||
const replacement = peer.acceptSignal(
|
||||
"host",
|
||||
offer("codec-connection", 6),
|
||||
);
|
||||
await peer.acceptSignal("host", offer("codec-connection", 6));
|
||||
const second = peer.acceptSignal("host", offer("same-connection"));
|
||||
await Promise.resolve();
|
||||
expect(connection.setRemoteDescription).toHaveBeenCalledOnce();
|
||||
|
||||
firstLocalDescription.resolve();
|
||||
await Promise.all([first, replacement]);
|
||||
expect(
|
||||
signals
|
||||
.filter((signal) => signal.kind === "description")
|
||||
.map((signal) => signal.negotiationGeneration),
|
||||
).toEqual([6]);
|
||||
await Promise.all([first, second]);
|
||||
expect(connection.setRemoteDescription).toHaveBeenCalledTimes(2);
|
||||
|
||||
await peer.acceptSignal("host", offer("codec-connection"));
|
||||
expect(
|
||||
signals
|
||||
.filter((signal) => signal.kind === "description")
|
||||
.map((signal) => signal.negotiationGeneration),
|
||||
).toEqual([6, null]);
|
||||
expect(connection.setRemoteDescription).toHaveBeenCalledTimes(3);
|
||||
|
||||
await peer.acceptSignal("host", offer("codec-connection", 5));
|
||||
expect(connection.setRemoteDescription).toHaveBeenCalledTimes(3);
|
||||
signals.filter((signal) => signal.kind === "description"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("stops flushing old candidates when the connection is replaced", async () => {
|
||||
|
||||
Reference in new issue
Block a user