fix(voice): confirm a decode stall before withdrawing a codec (#2661)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 12:53:29 +02:00
1 parent 7d8778495f
commit 74782dc4f2
4 files changed
+281 -27

No files matched your search

@@ -2,7 +2,7 @@
import type {FluxerCodecAdvertisement} from '@app/features/voice/engine/ScreenShareCodecNegotiation'; import type {FluxerCodecAdvertisement} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities'; import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
import {beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const gpuReport: HardwareEncodeReport = { const gpuReport: HardwareEncodeReport = {
av1: 'hardware', av1: 'hardware',
@@ -77,13 +77,15 @@ const {
buildLocalCodecAdvertisements, buildLocalCodecAdvertisements,
computeNegotiatedVideoCodec, computeNegotiatedVideoCodec,
} = await import('./ScreenShareCodecNegotiation'); } = await import('./ScreenShareCodecNegotiation');
const {findStalledVideoDecoder} = await import('@app/features/voice/utils/ScreenShareCodecDiagnostics'); const {findStalledVideoDecoder, scheduleScreenShareDecoderVerification} = await import(
'@app/features/voice/utils/ScreenShareCodecDiagnostics'
);
const {getVideoDecoderExclusionsSync, markScreenShareDecodeFailure, resetVideoDecoderExclusions} = await import( const {getVideoDecoderExclusionsSync, markScreenShareDecodeFailure, resetVideoDecoderExclusions} = await import(
'@app/features/voice/utils/VideoDecoderCapabilities' '@app/features/voice/utils/VideoDecoderCapabilities'
); );
const {resetCachedCodecCapabilities} = await import('@app/features/voice/utils/CodecCapabilityDetector'); const {resetCachedCodecCapabilities} = await import('@app/features/voice/utils/CodecCapabilityDetector');
function stalledScreenShareStats(mimeType: string): RTCStatsReport { function screenShareStats(mimeType: string, frames: {framesReceived: number; framesDecoded: number}): RTCStatsReport {
const entries: Array<Record<string, unknown>> = [ const entries: Array<Record<string, unknown>> = [
{id: 'codec-1', type: 'codec', mimeType}, {id: 'codec-1', type: 'codec', mimeType},
{ {
@@ -93,13 +95,23 @@ function stalledScreenShareStats(mimeType: string): RTCStatsReport {
codecId: 'codec-1', codecId: 'codec-1',
packetsReceived: 4200, packetsReceived: 4200,
bytesReceived: 3_500_000, bytesReceived: 3_500_000,
framesReceived: 180, decoderImplementation: 'D3D11VideoDecoder',
framesDecoded: 0, powerEfficientDecoder: true,
...frames,
}, },
]; ];
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport; return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
} }
function stalledScreenShareStats(mimeType: string): RTCStatsReport {
return screenShareStats(mimeType, {framesReceived: 180, framesDecoded: 0});
}
function statsSequence(...reports: Array<RTCStatsReport>): () => Promise<RTCStatsReport | undefined> {
const queue = [...reports];
return () => Promise.resolve(queue.shift());
}
function decodeAdvertisedFor(name: 'H264' | 'VP8' | 'VP9'): boolean | undefined { function decodeAdvertisedFor(name: 'H264' | 'VP8' | 'VP9'): boolean | undefined {
return buildLocalCodecAdvertisements().find((codec) => codec.name === name)?.decode; return buildLocalCodecAdvertisements().find((codec) => codec.name === name)?.decode;
} }
@@ -164,3 +176,119 @@ describe('a stalled H.264 screen share decode', () => {
expect(decodeAdvertisedFor('VP8')).toBe(true); expect(decodeAdvertisedFor('VP8')).toBe(true);
}); });
}); });
describe('confirming a screen share decode stall before a codec is withdrawn', () => {
beforeEach(() => {
vi.useFakeTimers();
resetVideoDecoderExclusions();
resetCachedCodecCapabilities();
ScreenShareCodecNegotiation.dispose();
});
afterEach(() => {
vi.useRealTimers();
});
it('withdraws nothing from a single stalled sample', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when packets arrive but no whole frames do', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws the codec exactly once when a second sample confirms the stall', async () => {
const withdrawn: Array<string> = [];
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
(failure) => {
if (markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) {
withdrawn.push(failure.codec);
}
},
);
await vi.advanceTimersByTimeAsync(5000);
expect(withdrawn).toEqual([]);
expect(decodeAdvertisedFor('H264')).toBe(true);
await vi.advanceTimersByTimeAsync(5000);
expect(withdrawn).toEqual(['h264']);
expect(decodeAdvertisedFor('H264')).toBe(false);
});
it('withdraws nothing when the second sample decoded a frame', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 12}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(10000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when no new frames arrived between the samples', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(10000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when the track goes away between the samples', async () => {
const onDecodeFailure = vi.fn();
const cancel = scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(vi.getTimerCount()).toBe(1);
cancel();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
});
@@ -141,7 +141,7 @@ export function bindRoomEvents(
): void { ): void {
const guard = dependencies.connection.createGuardedHandler; const guard = dependencies.connection.createGuardedHandler;
const participantSpeakingDisposers = new Map<string, ParticipantSpeakingDisposer>(); const participantSpeakingDisposers = new Map<string, ParticipantSpeakingDisposer>();
const screenShareDecoderVerificationTimers = new Map<string, NodeJS.Timeout>(); const screenShareDecoderVerificationCancels = new Map<string, () => void>();
const remoteTrackLifecycleDisposers = new Map<string, () => void>(); const remoteTrackLifecycleDisposers = new Map<string, () => void>();
let codecNegotiationDisposer: (() => void) | null = null; let codecNegotiationDisposer: (() => void) | null = null;
let screenShareMigrationDisposer: (() => void) | null = null; let screenShareMigrationDisposer: (() => void) | null = null;
@@ -227,16 +227,16 @@ export function bindRoomEvents(
}; };
const clearScreenShareDecoderVerification = (trackSid: string | undefined): void => { const clearScreenShareDecoderVerification = (trackSid: string | undefined): void => {
if (!trackSid) return; if (!trackSid) return;
const timer = screenShareDecoderVerificationTimers.get(trackSid); const cancel = screenShareDecoderVerificationCancels.get(trackSid);
if (!timer) return; if (!cancel) return;
clearTimeout(timer); cancel();
screenShareDecoderVerificationTimers.delete(trackSid); screenShareDecoderVerificationCancels.delete(trackSid);
}; };
const clearAllScreenShareDecoderVerifications = (): void => { const clearAllScreenShareDecoderVerifications = (): void => {
for (const timer of screenShareDecoderVerificationTimers.values()) { for (const cancel of screenShareDecoderVerificationCancels.values()) {
clearTimeout(timer); cancel();
} }
screenShareDecoderVerificationTimers.clear(); screenShareDecoderVerificationCancels.clear();
}; };
const bindCodecNegotiation = (): void => { const bindCodecNegotiation = (): void => {
codecNegotiationDisposer?.(); codecNegotiationDisposer?.();
@@ -258,12 +258,12 @@ export function bindRoomEvents(
if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return; if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return;
clearScreenShareDecoderVerification(pub.trackSid); clearScreenShareDecoderVerification(pub.trackSid);
const trackSid = pub.trackSid; const trackSid = pub.trackSid;
screenShareDecoderVerificationTimers.set( screenShareDecoderVerificationCancels.set(
trackSid, trackSid,
scheduleScreenShareDecoderVerification( scheduleScreenShareDecoderVerification(
() => track.getRTCStatsReport(), () => track.getRTCStatsReport(),
() => { () => {
screenShareDecoderVerificationTimers.delete(trackSid); screenShareDecoderVerificationCancels.delete(trackSid);
}, },
(failure) => { (failure) => {
if (!markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) return; if (!markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) return;
@@ -2,15 +2,29 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {
confirmDecodeStall,
findSoftwareVideoDecoder, findSoftwareVideoDecoder,
findStalledVideoDecoder, findStalledVideoDecoder,
isSoftwareVideoImplementation, isSoftwareVideoImplementation,
type StalledVideoDecoderInfo,
} from './ScreenShareCodecDiagnostics'; } from './ScreenShareCodecDiagnostics';
function createStatsReport(entries: Array<Record<string, unknown>>): RTCStatsReport { function createStatsReport(entries: Array<Record<string, unknown>>): RTCStatsReport {
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport; return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
} }
function createStallInfo(overrides: Partial<StalledVideoDecoderInfo> = {}): StalledVideoDecoderInfo {
return {
codec: 'h264',
mimeType: 'video/H264',
packetsReceived: 400,
bytesReceived: 250_000,
framesDecoded: 0,
framesReceived: 20,
...overrides,
};
}
describe('isSoftwareVideoImplementation', () => { describe('isSoftwareVideoImplementation', () => {
it('detects common software encoder and decoder implementations', () => { it('detects common software encoder and decoder implementations', () => {
expect(isSoftwareVideoImplementation('libvpx')).toBe(true); expect(isSoftwareVideoImplementation('libvpx')).toBe(true);
@@ -133,4 +147,76 @@ describe('findStalledVideoDecoder', () => {
]); ]);
expect(findStalledVideoDecoder(stats)).toBeNull(); expect(findStalledVideoDecoder(stats)).toBeNull();
}); });
it('does not treat a first keyframe that is still arriving as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 40,
bytesReceived: 60_000,
framesReceived: 0,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toBeNull();
});
it('does not treat packets that carry no video frames as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 50,
bytesReceived: 0,
framesReceived: 0,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toBeNull();
});
it('treats whole frames that never decode as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 40,
bytesReceived: 60_000,
framesReceived: 40,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toMatchObject({codec: 'h264', framesReceived: 40, framesDecoded: 0});
});
});
describe('confirmDecodeStall', () => {
it('confirms a stall when more frames arrived and none of them decoded', () => {
const second = createStallInfo({framesReceived: 45});
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), second)).toBe(second);
});
it('does not confirm a stall when the second sample decoded a frame', () => {
expect(confirmDecodeStall(createStallInfo(), createStallInfo({framesReceived: 45, framesDecoded: 3}))).toBeNull();
});
it('does not confirm a stall when the second sample is missing', () => {
expect(confirmDecodeStall(createStallInfo(), null)).toBeNull();
});
it('does not confirm a stall when no new frames arrived between the samples', () => {
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), createStallInfo({framesReceived: 20}))).toBeNull();
});
it('does not confirm a stall across two different codecs', () => {
expect(
confirmDecodeStall(
createStallInfo({framesReceived: 20}),
createStallInfo({codec: 'vp9', mimeType: 'video/VP9', framesReceived: 45}),
),
).toBeNull();
});
}); });
@@ -46,7 +46,7 @@ export interface StalledVideoDecoderInfo {
packetsReceived: number; packetsReceived: number;
bytesReceived: number; bytesReceived: number;
framesDecoded: number; framesDecoded: number;
framesReceived: number | null; framesReceived: number;
} }
function isSoftwareVideoStats(implementation: string | null, powerEfficient: boolean | null): boolean { function isSoftwareVideoStats(implementation: string | null, powerEfficient: boolean | null): boolean {
@@ -127,11 +127,10 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
if (getStatsKind(report, codecs) !== 'video') continue; if (getStatsKind(report, codecs) !== 'video') continue;
const framesDecoded = finiteNumber(report.framesDecoded); const framesDecoded = finiteNumber(report.framesDecoded);
if (framesDecoded === null || framesDecoded > 0) continue; if (framesDecoded === null || framesDecoded > 0) continue;
const framesReceived = finiteNumber(report.framesReceived);
if (framesReceived === null || framesReceived < 1) continue;
const packetsReceived = finiteNumber(report.packetsReceived) ?? 0; const packetsReceived = finiteNumber(report.packetsReceived) ?? 0;
const bytesReceived = finiteNumber(report.bytesReceived) ?? 0; const bytesReceived = finiteNumber(report.bytesReceived) ?? 0;
const framesReceived = finiteNumber(report.framesReceived);
const hasVideoPayload = packetsReceived >= 10 || bytesReceived >= 8192 || (framesReceived ?? 0) >= 2;
if (!hasVideoPayload) continue;
const mimeType = report.codecId ? codecs.get(report.codecId)?.mimeType : undefined; const mimeType = report.codecId ? codecs.get(report.codecId)?.mimeType : undefined;
const codec = getVideoCodecFromMimeType(mimeType); const codec = getVideoCodecFromMimeType(mimeType);
if (!codec) continue; if (!codec) continue;
@@ -147,20 +146,49 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
return null; return null;
} }
export function confirmDecodeStall(
first: StalledVideoDecoderInfo | null,
second: StalledVideoDecoderInfo | null,
): StalledVideoDecoderInfo | null {
if (!first || !second) return null;
if (first.codec !== second.codec || first.mimeType !== second.mimeType) return null;
if (first.framesDecoded !== 0 || second.framesDecoded !== 0) return null;
if (second.framesReceived <= first.framesReceived) return null;
return second;
}
export function scheduleScreenShareDecoderVerification( export function scheduleScreenShareDecoderVerification(
getStats: () => Promise<RTCStatsReport | undefined>, getStats: () => Promise<RTCStatsReport | undefined>,
onComplete?: () => void, onComplete?: () => void,
onDecodeFailure?: (failure: StalledVideoDecoderInfo) => void, onDecodeFailure?: (failure: StalledVideoDecoderInfo) => void,
): NodeJS.Timeout { ): () => void {
return setTimeout(async () => { let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const scheduleConfirmation = (first: StalledVideoDecoderInfo): void => {
timer = setTimeout(async () => {
timer = null;
try {
const stats = await getStats();
const confirmed = confirmDecodeStall(first, stats ? findStalledVideoDecoder(stats) : null);
if (cancelled || !confirmed) return;
logger.warn('Screen share video decode is stalled', confirmed);
onDecodeFailure?.(confirmed);
} catch (error) {
logger.debug('Failed to confirm the screen share decode stall', {error});
} finally {
if (!cancelled) {
onComplete?.();
}
}
}, DECODER_VERIFICATION_DELAY_MS);
};
timer = setTimeout(async () => {
timer = null;
let firstStall: StalledVideoDecoderInfo | null = null;
try { try {
const stats = await getStats(); const stats = await getStats();
if (!stats) return; if (!stats) return;
const stalledDecoder = findStalledVideoDecoder(stats); firstStall = findStalledVideoDecoder(stats);
if (stalledDecoder) {
logger.warn('Screen share video decode is stalled', stalledDecoder);
onDecodeFailure?.(stalledDecoder);
}
const decoder = findSoftwareVideoDecoder(stats); const decoder = findSoftwareVideoDecoder(stats);
if (!decoder) return; if (!decoder) return;
logger.warn('Screen share is using a software decoder', decoder); logger.warn('Screen share is using a software decoder', decoder);
@@ -168,7 +196,19 @@ export function scheduleScreenShareDecoderVerification(
} catch (error) { } catch (error) {
logger.debug('Failed to verify screen share decoder', {error}); logger.debug('Failed to verify screen share decoder', {error});
} finally { } finally {
onComplete?.(); if (!cancelled) {
if (firstStall) {
scheduleConfirmation(firstStall);
} else {
onComplete?.();
}
}
} }
}, DECODER_VERIFICATION_DELAY_MS); }, DECODER_VERIFICATION_DELAY_MS);
return () => {
cancelled = true;
if (!timer) return;
clearTimeout(timer);
timer = null;
};
} }