fix(voice): guard stale screen-share negotiation and probes (#2389)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:34:10 +02:00
1 parent 59f6217267
commit f1734704ef
2 files changed
+60 -14

No files matched your search

@@ -506,6 +506,8 @@ class ScreenShareCodecNegotiation {
private rtcConnectionId = createId('rtc'); private rtcConnectionId = createId('rtc');
private mediaSessionId = createId('media'); private mediaSessionId = createId('media');
private negotiationSnapshot = createScreenShareCodecNegotiationSnapshot(); private negotiationSnapshot = createScreenShareCodecNegotiationSnapshot();
private bindingRevision = 0;
private selectionRevision = 0;
getSelectedCodec(): VideoCodec | null { getSelectedCodec(): VideoCodec | null {
return this.selectedCodec; return this.selectedCodec;
@@ -582,6 +584,7 @@ class ScreenShareCodecNegotiation {
this.dispose(); this.dispose();
this.room = room; this.room = room;
this.bindOptions = options; this.bindOptions = options;
const bindingRevision = this.bindingRevision;
const onDataReceived = ( const onDataReceived = (
payload: Uint8Array, payload: Uint8Array,
participant: Participant | undefined, participant: Participant | undefined,
@@ -589,23 +592,24 @@ class ScreenShareCodecNegotiation {
topic?: string, topic?: string,
): void => { ): void => {
if (topic !== PROTOCOL_TOPIC || !participant) return; if (topic !== PROTOCOL_TOPIC || !participant) return;
this.handleDataMessage(room, participant, payload, options); this.handleDataMessage(room, participant, payload, options, bindingRevision);
}; };
const onParticipantConnected = (): void => { const onParticipantConnected = (): void => {
void this.publishLocalCapabilities(room, 'participant-connected', options); void this.publishBoundLocalCapabilities(room, 'participant-connected', options, bindingRevision);
}; };
const onParticipantDisconnected = (participant: Participant): void => { const onParticipantDisconnected = (participant: Participant): void => {
if (!this.isBindingCurrent(room, bindingRevision)) return;
this.remoteCodecsByIdentity.delete(participant.identity); this.remoteCodecsByIdentity.delete(participant.identity);
void this.updateSelection(room, 'participant-disconnected', options); void this.updateSelection(room, 'participant-disconnected', options, bindingRevision);
}; };
const onReconnected = (): void => { const onReconnected = (): void => {
void this.publishLocalCapabilities(room, 'reconnected', options); void this.publishBoundLocalCapabilities(room, 'reconnected', options, bindingRevision);
}; };
room.on(RoomEvent.DataReceived, onDataReceived); room.on(RoomEvent.DataReceived, onDataReceived);
room.on(RoomEvent.ParticipantConnected, onParticipantConnected); room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.on(RoomEvent.Reconnected, onReconnected); room.on(RoomEvent.Reconnected, onReconnected);
void this.publishLocalCapabilities(room, 'connected', options); void this.publishBoundLocalCapabilities(room, 'connected', options, bindingRevision);
this.bindDisposer = () => { this.bindDisposer = () => {
room.off(RoomEvent.DataReceived, onDataReceived); room.off(RoomEvent.DataReceived, onDataReceived);
room.off(RoomEvent.ParticipantConnected, onParticipantConnected); room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
@@ -616,6 +620,8 @@ class ScreenShareCodecNegotiation {
} }
dispose(): void { dispose(): void {
this.bindingRevision += 1;
this.selectionRevision += 1;
this.bindDisposer?.(); this.bindDisposer?.();
this.bindDisposer = null; this.bindDisposer = null;
this.room = null; this.room = null;
@@ -631,16 +637,26 @@ class ScreenShareCodecNegotiation {
room: Room | null = this.room, room: Room | null = this.room,
reason: NegotiationReason = 'manual', reason: NegotiationReason = 'manual',
options: BindOptions = this.bindOptions, options: BindOptions = this.bindOptions,
): Promise<CodecNegotiationSelection | null> {
if (!room) return null;
return await this.publishBoundLocalCapabilities(room, reason, options, this.bindingRevision);
}
private async publishBoundLocalCapabilities(
room: Room,
reason: NegotiationReason,
options: BindOptions,
bindingRevision: number,
): Promise<CodecNegotiationSelection | null> { ): Promise<CodecNegotiationSelection | null> {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
if (!room?.localParticipant) return null; if (!room.localParticipant || !this.isBindingCurrent(room, bindingRevision)) return null;
this.room = room;
await Promise.allSettled([ await Promise.allSettled([
loadGpuEncoderReport(), loadGpuEncoderReport(),
loadNativeHardwareEncoderCapabilities(), loadNativeHardwareEncoderCapabilities(),
loadVideoDecoderExclusions(), loadVideoDecoderExclusions(),
loadOpenH264Status(), loadOpenH264Status(),
]); ]);
if (!this.isBindingCurrent(room, bindingRevision)) return null;
this.localCodecs = buildLocalCodecAdvertisements(); this.localCodecs = buildLocalCodecAdvertisements();
const message: FluxerSelectProtocolMessage = { const message: FluxerSelectProtocolMessage = {
op: SELECT_PROTOCOL_OP, op: SELECT_PROTOCOL_OP,
@@ -655,15 +671,23 @@ class ScreenShareCodecNegotiation {
}, },
}; };
await this.publishMessage(room, message); await this.publishMessage(room, message);
return await this.updateSelection(room, reason, options); if (!this.isBindingCurrent(room, bindingRevision)) return null;
return await this.updateSelection(room, reason, options, bindingRevision);
} }
private handleDataMessage(room: Room, participant: Participant, payload: Uint8Array, options: BindOptions): void { private handleDataMessage(
room: Room,
participant: Participant,
payload: Uint8Array,
options: BindOptions,
bindingRevision: number,
): void {
if (!this.isBindingCurrent(room, bindingRevision)) return;
const message = parseMessage(payload); const message = parseMessage(payload);
if (!message) return; if (!message) return;
if (message.op === SELECT_PROTOCOL_OP) { if (message.op === SELECT_PROTOCOL_OP) {
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs); this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
void this.updateSelection(room, 'data', options); void this.updateSelection(room, 'data', options, bindingRevision);
} else if (message.op === SESSION_UPDATE_OP) { } else if (message.op === SESSION_UPDATE_OP) {
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs); this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
logger.debug('Received remote codec session update', { logger.debug('Received remote codec session update', {
@@ -672,7 +696,7 @@ class ScreenShareCodecNegotiation {
mediaSessionId: message.d.media_session_id, mediaSessionId: message.d.media_session_id,
reason: message.d.reason, reason: message.d.reason,
}); });
void this.updateSelection(room, 'data', options); void this.updateSelection(room, 'data', options, bindingRevision);
} }
} }
@@ -680,8 +704,9 @@ class ScreenShareCodecNegotiation {
room: Room, room: Room,
reason: NegotiationReason, reason: NegotiationReason,
options: BindOptions, options: BindOptions,
bindingRevision: number,
): Promise<CodecNegotiationSelection | null> { ): Promise<CodecNegotiationSelection | null> {
if (!room.localParticipant) return null; if (!room.localParticipant || !this.isBindingCurrent(room, bindingRevision)) return null;
if (this.localCodecs.length === 0) this.localCodecs = buildLocalCodecAdvertisements(); if (this.localCodecs.length === 0) this.localCodecs = buildLocalCodecAdvertisements();
if (reason === 'participant-disconnected' && room.remoteParticipants.size === 0 && this.selectedCodec) { if (reason === 'participant-disconnected' && room.remoteParticipants.size === 0 && this.selectedCodec) {
logger.debug('Keeping active screen share codec after last viewer disconnected', { logger.debug('Keeping active screen share codec after last viewer disconnected', {
@@ -703,13 +728,20 @@ class ScreenShareCodecNegotiation {
if (!selection) return null; if (!selection) return null;
this.selectedCodec = selection.codec; this.selectedCodec = selection.codec;
if (selection.codec === previousCodec) return selection; if (selection.codec === previousCodec) return selection;
const selectionRevision = ++this.selectionRevision;
this.mediaSessionId = createId('media'); this.mediaSessionId = createId('media');
logger.info('Selected screen share codec from XState capability intersection', selection); logger.info('Selected screen share codec from XState capability intersection', selection);
await this.publishSessionUpdate(room, selection); await this.publishSessionUpdate(room, selection);
if (!this.isBindingCurrent(room, bindingRevision) || selectionRevision !== this.selectionRevision) return null;
await options.onSelectedCodecChanged?.(selection); await options.onSelectedCodecChanged?.(selection);
if (!this.isBindingCurrent(room, bindingRevision) || selectionRevision !== this.selectionRevision) return null;
return selection; return selection;
} }
private isBindingCurrent(room: Room, bindingRevision: number): boolean {
return this.room === room && this.bindingRevision === bindingRevision;
}
private async publishSessionUpdate(room: Room, selection: CodecNegotiationSelection): Promise<void> { private async publishSessionUpdate(room: Room, selection: CodecNegotiationSelection): Promise<void> {
const message: FluxerSessionUpdateMessage = { const message: FluxerSessionUpdateMessage = {
op: SESSION_UPDATE_OP, op: SESSION_UPDATE_OP,
@@ -324,6 +324,14 @@ function safeSetEnabled(publication: RemoteTrackPublication, enabled: boolean):
} }
} }
function getRemoteTrackStatsReport(track: RemoteTrack): Promise<RTCStatsReport | undefined> | undefined {
try {
return track.getRTCStatsReport?.();
} catch {
return undefined;
}
}
function publishMigrationMessage( function publishMigrationMessage(
participant: LocalParticipant, participant: LocalParticipant,
message: ScreenShareMigrationMessage, message: ScreenShareMigrationMessage,
@@ -820,6 +828,7 @@ class ScreenSharePublicationMigration extends Store {
let disposed = false; let disposed = false;
let video: HTMLVideoElement | null = null; let video: HTMLVideoElement | null = null;
let statsInterval: NodeJS.Timeout | null = null; let statsInterval: NodeJS.Timeout | null = null;
let statsRequestPending = false;
const cleanups: Array<() => void> = []; const cleanups: Array<() => void> = [];
const complete = (): void => { const complete = (): void => {
if (disposed) return; if (disposed) return;
@@ -850,8 +859,10 @@ class ScreenSharePublicationMigration extends Store {
cleanups.push(() => clearTimeout(expireTimer)); cleanups.push(() => clearTimeout(expireTimer));
const checkStats = (): void => { const checkStats = (): void => {
const statsPromise = track.getRTCStatsReport?.(); if (disposed || statsRequestPending) return;
const statsPromise = getRemoteTrackStatsReport(track);
if (!statsPromise) return; if (!statsPromise) return;
statsRequestPending = true;
void statsPromise void statsPromise
.then((report) => { .then((report) => {
if (!report) return; if (!report) return;
@@ -870,7 +881,10 @@ class ScreenSharePublicationMigration extends Store {
} }
} }
}) })
.catch(() => {}); .catch(() => {})
.finally(() => {
statsRequestPending = false;
});
}; };
statsInterval = setInterval(checkStats, REMOTE_READY_PROBE_STATS_INTERVAL_MS); statsInterval = setInterval(checkStats, REMOTE_READY_PROBE_STATS_INTERVAL_MS);
checkStats(); checkStats();