refactor(browser): simplify codec ownership

This commit is contained in:
AAA committed 2026-08-24 10:18:23 +08:00
1 parent 05265c460d
commit 1b7655a34d
35 files changed
+500 -7862

No files matched your search

+3 -44
View File
@@ -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;
-187
View File
@@ -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";
}
+7 -94
View File
@@ -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> {
-53
View File
@@ -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),
};
}
+3 -3
View File
@@ -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")
);
}
+2 -22
View File
@@ -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(
+3 -37
View File
@@ -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
View File
@@ -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)}
>
+3 -106
View File
@@ -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();
+3 -16
View File
@@ -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;
}
+2 -3
View File
@@ -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
View File
@@ -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 ||
+1 -1
View File
@@ -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;
}
+6 -36
View File
@@ -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();
-19
View File
@@ -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
+16 -491
View File
@@ -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,
};
}
+6 -615
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
-134
View File
@@ -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
+7 -16
View File
@@ -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
View File
@@ -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);
-71
View File
@@ -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();
});
});
-347
View File
@@ -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);
-1
View File
@@ -13,7 +13,6 @@ import {
const offer: SignalPayload = {
kind: "description",
connectionId: "connection_12345678",
negotiationGeneration: null,
description: { type: "offer", sdp: "test-offer" },
};
+47 -95
View File
@@ -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);
+6
View File
@@ -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", () => {
-637
View File
@@ -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
View File
@@ -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
View File
@@ -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" });
+18 -1
View File
@@ -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 });
+7 -28
View File
@@ -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 () => {