From c541b86c0093dddb501dc58296a2b9c0d811f750 Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 11 Sep 2026 15:03:21 +0200 Subject: [PATCH] fix(voice): show buffering while screen share recovery runs (#2682) --- .../voice/components/VoiceParticipantTile.tsx | 23 +++- .../VoiceParticipantTileStateMachine.test.ts | 14 +++ .../VoiceParticipantTileStateMachine.ts | 4 + ...reenShareVideoSubscriptionRecovery.test.ts | 108 +++++++++++++++--- .../ScreenShareVideoSubscriptionRecovery.ts | 98 ++++++++++++---- .../engine/VoiceMediaGraphTileState.test.ts | 69 ++++++++++- .../voice/engine/VoiceMediaGraphTileState.ts | 35 +++++- 7 files changed, 302 insertions(+), 49 deletions(-) diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx index 26886b019..940d7f55e 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx @@ -76,6 +76,7 @@ import { } from '@app/features/voice/components/voice_participant_tile/hooks'; import LastFrameSnapshotCache from '@app/features/voice/components/voice_participant_tile/LastFrameSnapshotCache'; import {ScreenSharePlaceholder} from '@app/features/voice/components/voice_participant_tile/ScreenSharePlaceholder'; +import {screenShareVideoSubscriptionRecoveryCoordinator} from '@app/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery'; import { CAMERA_BUFFERING_DESCRIPTOR, CAMERA_HIDDEN_DESCRIPTOR, @@ -113,6 +114,7 @@ import { selectVoiceMediaGraphDeferredStopKeys, selectVoiceMediaGraphFailure, selectVoiceMediaGraphViewerStreamKeys, + selectVoiceMediaGraphWatchGeneration, } from '@app/features/voice/engine/VoiceMediaGraph'; import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore'; import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState'; @@ -405,11 +407,22 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ const isConnectedToTileChannel = Boolean(channelId) && MediaEngine.channelId === channelId && MediaEngine.guildId === (guildId ?? null); const isWatching = isConnectedToTileChannel && graphViewerStreamKeys.includes(streamKey); - const graphTileState = selectVoiceMediaGraphStreamTileState(graphSnapshot, { - streamKey: streamKey || null, - participantIdentity: identity || null, - source: VoiceTrackSource.ScreenShare, - }); + const graphWatchGeneration = selectVoiceMediaGraphWatchGeneration(graphSnapshot, streamKey); + const graphTileState = selectVoiceMediaGraphStreamTileState( + graphSnapshot, + { + streamKey: streamKey || null, + participantIdentity: identity || null, + source: VoiceTrackSource.ScreenShare, + }, + { + hasRecoveryBudget: screenShareVideoSubscriptionRecoveryCoordinator.hasFirstFrameRecoveryBudget( + streamKey, + graphWatchGeneration, + ), + nowMs: voiceMediaGraphStore.nowMs(), + }, + ); const graphWatchFailure = isScreenShare ? selectVoiceMediaGraphFailure(graphSnapshot, { streamKey, diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts index 6ef5ce10d..4583dee69 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts @@ -30,6 +30,7 @@ const GRAPH_TILE_STATES: ReadonlyArray = [ 'attaching', 'subscribedAwaitingFrame', 'rendering', + 'recovering', 'failed', ]; @@ -39,6 +40,7 @@ const WATCH_INTENT_GRAPH_TILE_STATES: ReadonlyArray { + expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering'}))).toBe('buffering'); + expect( + selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering', isTrackReference: false})), + ).toBe('buffering'); + }); + + it('holds the error code back until recovery has given up', () => { + expect(shouldShowWatchFailed(signals({graphTileState: 'recovering'}))).toBe(false); + expect(shouldShowWatchFailed(signals({graphTileState: 'failed'}))).toBe(true); + }); + it('shows the watch failed overlay when the graph reports a failure', () => { expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'failed'}))).toBe('watchFailed'); expect( diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts index 0418bc2e0..689b61c6c 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts @@ -87,6 +87,8 @@ export function graphTileStateHoldsWatchIntent(graphTileState: VoiceMediaGraphSt return true; case 'rendering': return true; + case 'recovering': + return true; case 'failed': return true; case 'idle': @@ -109,6 +111,8 @@ export function shouldShowScreenShareBuffering(signals: VoiceParticipantTileScre return true; case 'subscribedAwaitingFrame': return true; + case 'recovering': + return true; case 'publicationMissing': if (signals.isRepublishGracePending) return true; return signals.isTrackReference; diff --git a/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.test.ts b/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.test.ts index fd05363f2..4725f7078 100644 --- a/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.test.ts +++ b/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.test.ts @@ -79,9 +79,9 @@ function createWatchedSnapshot(failure: Partial | null): }); } -function createGraphPort(snapshot: VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph { +function createGraphPort(getSnapshot: () => VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph { return { - getGraphSnapshot: () => snapshot, + getGraphSnapshot: getSnapshot, nowMs: () => 1000, transition: () => undefined, }; @@ -95,20 +95,47 @@ function createReceivablePublication(): ScreenShareVideoSubscriptionRecoveryPubl }; } -function acquireWatchingTile(failure: Partial | null) { - const snapshot = createWatchedSnapshot(failure); +function createMutedPublication(): ScreenShareVideoSubscriptionRecoveryPublication { + return { + trackSid: TRACK_SID, + isSubscribed: true, + track: {mediaStreamTrack: {readyState: 'live', muted: true}}, + }; +} + +function createWatchingTile( + failure: Partial | null, + publication: ScreenShareVideoSubscriptionRecoveryPublication = createReceivablePublication(), +) { + let snapshot = createWatchedSnapshot(failure); const {scheduler, fire, pendingCount, scheduledDelays} = createScheduler(); - const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(scheduler, createGraphPort(snapshot)); + const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator( + scheduler, + createGraphPort(() => snapshot), + ); const recover = vi.fn(); - coordinator.acquire({ - key: TRACK_SID, - publication: createReceivablePublication(), - streamKey: STREAM_KEY, - participantIdentity: PARTICIPANT_IDENTITY, - isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY), - recover, - }); - return {coordinator, fire, pendingCount, scheduledDelays, recover}; + const acquire = () => + coordinator.acquire({ + key: TRACK_SID, + publication, + streamKey: STREAM_KEY, + participantIdentity: PARTICIPANT_IDENTITY, + isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY), + recover, + }); + const restartWatch = (nextFailure: Partial | null) => { + snapshot = createWatchedSnapshot(nextFailure); + }; + return {coordinator, fire, pendingCount, scheduledDelays, recover, acquire, restartWatch}; +} + +function acquireWatchingTile( + failure: Partial | null, + publication?: ScreenShareVideoSubscriptionRecoveryPublication, +) { + const tile = createWatchingTile(failure, publication); + tile.acquire(); + return tile; } describe('isScreenShareVideoSubscriptionRecoveryWanted', () => { @@ -180,4 +207,57 @@ describe('ScreenShareVideoSubscriptionRecoveryCoordinator first-frame recovery', expect(recover).not.toHaveBeenCalled(); expect(coordinator.getActiveSessionCount()).toBe(0); }); + + it('keeps the recovery count when the lease is released and taken again', () => { + const {coordinator, fire, recover, acquire} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE); + const release = acquire(); + + fire(); + + expect(recover).toHaveBeenCalledTimes(1); + + release(); + + expect(coordinator.getActiveSessionCount()).toBe(0); + + acquire(); + fire(); + fire(); + + expect(recover).toHaveBeenCalledTimes(3); + + fire(); + + expect(recover).toHaveBeenCalledTimes(3); + expect(coordinator.getActiveSessionCount()).toBe(0); + }); + + it('hands the next watch of the same stream a fresh recovery budget', () => { + const {fire, recover, acquire, restartWatch} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE); + const release = acquire(); + + fire(); + fire(); + fire(); + + expect(recover).toHaveBeenCalledTimes(3); + + release(); + restartWatch(null); + acquire(); + restartWatch(FIRST_FRAME_TIMEOUT_FAILURE); + fire(); + + expect(recover).toHaveBeenCalledTimes(4); + }); + + it('leaves a muted track alone until a failure is recorded', () => { + const {coordinator, fire, recover} = acquireWatchingTile(null, createMutedPublication()); + + fire(); + fire(); + + expect(recover).not.toHaveBeenCalled(); + expect(coordinator.getActiveSessionCount()).toBe(1); + }); }); diff --git a/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.ts b/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.ts index ff4962002..69e3a98b7 100644 --- a/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.ts +++ b/fluxer_app/src/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery.ts @@ -62,7 +62,6 @@ interface ScreenShareVideoSubscriptionRecoverySession { key: string; leaseCount: number; attempt: number; - firstFrameRecoveries: number; timeoutId: number | null; graphGeneration: number | null; options: ScreenShareVideoSubscriptionRecoveryLeaseOptions; @@ -74,6 +73,7 @@ const SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS = 2500; const SCREEN_SHARE_VIDEO_SUBSCRIPTION_REFRESH_ATTEMPTS = 0; const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS = 3; const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS = 20_000; +const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS = 32; const defaultScheduler: ScreenShareVideoSubscriptionRecoveryScheduler = { setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs), @@ -97,20 +97,28 @@ function asScreenShareRecoveryTrackLike(track: unknown): ScreenShareRecoveryTrac return track as ScreenShareRecoveryTrackLike; } -function isTrackReceivable(track: ScreenShareRecoveryTrackLike): boolean { +function isTrackReceivable(track: ScreenShareRecoveryTrackLike, {allowMuted}: {allowMuted: boolean}): boolean { const mediaStreamTrack = track.mediaStreamTrack; if (mediaStreamTrack?.readyState === 'ended') return false; + if (allowMuted) return true; if (mediaStreamTrack?.muted === true) return false; if (track.isMuted === true) return false; if (track.muted === true) return false; return true; } -function hasReceivableTrack(publication: ScreenShareVideoSubscriptionRecoveryPublication): boolean { +function hasReceivableTrack( + publication: ScreenShareVideoSubscriptionRecoveryPublication, + {allowMuted}: {allowMuted: boolean}, +): boolean { if (publication.isSubscribed === false) return false; const track = asScreenShareRecoveryTrackLike(publication.track); if (track === null) return false; - return isTrackReceivable(track); + return isTrackReceivable(track, {allowMuted}); +} + +function firstFrameRecoveryKey(streamKey: string, generation: number): string { + return `${streamKey}:${generation}`; } export function isScreenShareVideoSubscriptionRecoveryWanted( @@ -151,6 +159,7 @@ export function selectScreenShareVideoSubscriptionRecoveryMode( export class ScreenShareVideoSubscriptionRecoveryCoordinator { private readonly sessions = new Map(); + private readonly firstFrameRecoveriesByWatch = new Map(); private readonly scheduler: ScreenShareVideoSubscriptionRecoveryScheduler; private readonly graph: ScreenShareVideoSubscriptionRecoveryGraph; @@ -163,13 +172,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { } acquire(options: ScreenShareVideoSubscriptionRecoveryLeaseOptions): () => void { + this.resetFirstFrameRecoveriesUnlessStalled(options.streamKey ?? null); let session = this.sessions.get(options.key); if (!session) { session = { key: options.key, leaseCount: 0, attempt: 0, - firstFrameRecoveries: 0, timeoutId: null, graphGeneration: null, options, @@ -198,6 +207,19 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { return this.sessions.size; } + getFirstFrameRecoveryCount(streamKey: string | null | undefined, generation: number): number { + if (!streamKey) return 0; + return this.firstFrameRecoveriesByWatch.get(firstFrameRecoveryKey(streamKey, generation)) ?? 0; + } + + hasFirstFrameRecoveryBudget(streamKey: string | null | undefined, generation: number): boolean { + if (!streamKey) return false; + return ( + this.getFirstFrameRecoveryCount(streamKey, generation) < + SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS + ); + } + private release(key: string): void { const session = this.sessions.get(key); if (!session) return; @@ -219,7 +241,8 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { private ensureScheduled(session: ScreenShareVideoSubscriptionRecoverySession): void { if (session.timeoutId !== null) return; - const delayMs = hasReceivableTrack(session.options.publication) + const allowMuted = this.firstFrameStalledStreamKey(session) === null; + const delayMs = hasReceivableTrack(session.options.publication, {allowMuted}) ? SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS : getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1); this.schedule(session, delayMs); @@ -230,9 +253,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { session.timeoutId = this.scheduler.setTimeout(() => this.run(session.key), delayMs); } + private watchGeneration(streamKey: string): number { + return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey); + } + private captureGraphGeneration(streamKey: string | null): number | null { if (!streamKey) return null; - return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey); + return this.watchGeneration(streamKey); } private isGraphStateCurrent(session: ScreenShareVideoSubscriptionRecoverySession): boolean { @@ -243,13 +270,35 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { return selectVoiceMediaGraphWatchGeneration(snapshot, streamKey) === session.graphGeneration; } - private hasFirstFrameTimeoutFailure(session: ScreenShareVideoSubscriptionRecoverySession): boolean { - const streamKey = session.options.streamKey ?? null; - if (!streamKey) return false; + private hasFirstFrameTimeout(streamKey: string): boolean { const failure = selectVoiceMediaGraphFailure(this.graph.getGraphSnapshot(), {streamKey}); return failure?.code === ScreenShareWatchErrorCode.FirstFrameTimeout; } + private firstFrameStalledStreamKey(session: ScreenShareVideoSubscriptionRecoverySession): string | null { + const streamKey = session.options.streamKey ?? null; + if (!streamKey) return null; + return this.hasFirstFrameTimeout(streamKey) ? streamKey : null; + } + + private resetFirstFrameRecoveriesUnlessStalled(streamKey: string | null): void { + if (!streamKey) return; + if (this.hasFirstFrameTimeout(streamKey)) return; + this.firstFrameRecoveriesByWatch.delete(firstFrameRecoveryKey(streamKey, this.watchGeneration(streamKey))); + } + + private noteFirstFrameRecovery(streamKey: string, generation: number): void { + const key = firstFrameRecoveryKey(streamKey, generation); + const next = this.getFirstFrameRecoveryCount(streamKey, generation) + 1; + this.firstFrameRecoveriesByWatch.delete(key); + this.firstFrameRecoveriesByWatch.set(key, next); + while (this.firstFrameRecoveriesByWatch.size > SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS) { + const oldest = this.firstFrameRecoveriesByWatch.keys().next().value; + if (oldest === undefined) break; + this.firstFrameRecoveriesByWatch.delete(oldest); + } + } + private run(key: string): void { const session = this.sessions.get(key); if (!session) return; @@ -259,20 +308,21 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { this.closeSession(session); return; } - const firstFrameStalled = this.hasFirstFrameTimeoutFailure(session); - if ( - firstFrameStalled && - session.firstFrameRecoveries >= SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS - ) { - this.closeSession(session); - return; + const stalledStreamKey = this.firstFrameStalledStreamKey(session); + if (stalledStreamKey === null) { + if (hasReceivableTrack(options.publication, {allowMuted: true})) { + session.attempt = 0; + this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS); + return; + } + } else { + const generation = this.watchGeneration(stalledStreamKey); + if (!this.hasFirstFrameRecoveryBudget(stalledStreamKey, generation)) { + this.closeSession(session); + return; + } + this.noteFirstFrameRecovery(stalledStreamKey, generation); } - if (!firstFrameStalled && hasReceivableTrack(options.publication)) { - session.attempt = 0; - this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS); - return; - } - if (firstFrameStalled) session.firstFrameRecoveries += 1; session.attempt += 1; const mode = selectScreenShareVideoSubscriptionRecoveryMode(session.attempt); const delayMs = getScreenShareVideoSubscriptionRetryDelayMs(session.attempt); @@ -286,7 +336,7 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator { this.recoverPublication(session, mode); this.schedule( session, - firstFrameStalled + stalledStreamKey !== null ? SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS : getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1), ); diff --git a/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.test.ts b/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.test.ts index 9909b53eb..778d26a88 100644 --- a/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.test.ts +++ b/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.test.ts @@ -5,12 +5,16 @@ import { transitionVoiceMediaGraph, type VoiceMediaGraphSnapshot, } from '@app/features/voice/engine/VoiceMediaGraph'; -import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState'; +import { + selectVoiceMediaGraphStreamTileState, + VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS, +} from '@app/features/voice/engine/VoiceMediaGraphTileState'; import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource'; import {describe, expect, it} from 'vitest'; const STREAM_KEY = 'dm:channel-a:connection-a'; const PARTICIPANT_IDENTITY = 'user_2_connection-a'; +const FAILURE_REPORTED_AT = 300; const target = { streamKey: STREAM_KEY, @@ -42,6 +46,24 @@ function attach(graph: VoiceMediaGraphSnapshot): VoiceMediaGraphSnapshot { }); } +function reportFailure(graph: VoiceMediaGraphSnapshot, code: number, reason: string): VoiceMediaGraphSnapshot { + return transitionVoiceMediaGraph(graph, { + type: 'failure.reported', + failure: { + code, + reason, + reportedAt: FAILURE_REPORTED_AT, + streamKey: STREAM_KEY, + participantIdentity: PARTICIPANT_IDENTITY, + source: 'screen_share', + }, + }); +} + +function watchedWithFailure(code: number, reason: string): VoiceMediaGraphSnapshot { + return reportFailure(attach(subscribe(createVoiceMediaGraphSnapshot(), true)), code, reason); +} + describe('selectVoiceMediaGraphStreamTileState', () => { it('returns idle when the graph has no state for the tile', () => { expect(selectVoiceMediaGraphStreamTileState(createVoiceMediaGraphSnapshot(), target)).toBe('idle'); @@ -140,6 +162,51 @@ describe('selectVoiceMediaGraphStreamTileState', () => { expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('subscribedAwaitingFrame'); }); + it('returns recovering while a first-frame timeout still has recovery attempts left', () => { + const graph = watchedWithFailure(-2303, 'first-frame-timeout'); + expect( + selectVoiceMediaGraphStreamTileState(graph, target, { + hasRecoveryBudget: true, + nowMs: FAILURE_REPORTED_AT + 1000, + }), + ).toBe('recovering'); + }); + + it('returns failed once the first-frame recovery budget is spent', () => { + const graph = watchedWithFailure(-2303, 'first-frame-timeout'); + expect( + selectVoiceMediaGraphStreamTileState(graph, target, { + hasRecoveryBudget: false, + nowMs: FAILURE_REPORTED_AT + 1000, + }), + ).toBe('failed'); + }); + + it('returns failed once a first-frame timeout has been on screen for the visible cap', () => { + const graph = watchedWithFailure(-2303, 'first-frame-timeout'); + expect( + selectVoiceMediaGraphStreamTileState(graph, target, { + hasRecoveryBudget: true, + nowMs: FAILURE_REPORTED_AT + VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS, + }), + ).toBe('failed'); + }); + + it('returns failed for a first-frame timeout when the caller tracks no recovery', () => { + const graph = watchedWithFailure(-2303, 'first-frame-timeout'); + expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('failed'); + }); + + it('keeps every other failure code failed while recovery attempts remain', () => { + const graph = watchedWithFailure(-2202, 'remote-track-subscription-failed'); + expect( + selectVoiceMediaGraphStreamTileState(graph, target, { + hasRecoveryBudget: true, + nowMs: FAILURE_REPORTED_AT + 1000, + }), + ).toBe('failed'); + }); + it('returns watchDesired for entry-less streams and idle after the watch ends', () => { let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), { type: 'watchIntent.add', diff --git a/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.ts b/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.ts index b2b66bea2..58205b1ec 100644 --- a/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.ts +++ b/fluxer_app/src/features/voice/engine/VoiceMediaGraphTileState.ts @@ -4,11 +4,15 @@ import { selectVoiceMediaGraphAttempt, selectVoiceMediaGraphFailure, selectVoiceMediaGraphSubscriptionEntry, + type VoiceMediaGraphFailure, type VoiceMediaGraphSnapshot, type VoiceMediaGraphSubscriptionEntry, } from './VoiceMediaGraph'; +import {VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE} from './VoiceMediaGraphDeadlines'; import type {VoiceTrackSource} from './VoiceTrackSource'; +export const VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS = 30_000; + export type VoiceMediaGraphStreamTileState = | 'idle' | 'watchDesired' @@ -16,6 +20,7 @@ export type VoiceMediaGraphStreamTileState = | 'attaching' | 'subscribedAwaitingFrame' | 'rendering' + | 'recovering' | 'failed'; export interface VoiceMediaGraphStreamTileTarget { @@ -24,14 +29,32 @@ export interface VoiceMediaGraphStreamTileTarget { source: VoiceTrackSource; } -function tileHasFailure(snapshot: VoiceMediaGraphSnapshot, target: VoiceMediaGraphStreamTileTarget): boolean { - if (!target.streamKey && !target.participantIdentity) return false; - const failure = selectVoiceMediaGraphFailure(snapshot, { +export interface VoiceMediaGraphStreamTileRecovery { + hasRecoveryBudget: boolean; + nowMs: number; +} + +function selectTileFailure( + snapshot: VoiceMediaGraphSnapshot, + target: VoiceMediaGraphStreamTileTarget, +): VoiceMediaGraphFailure | null { + if (!target.streamKey && !target.participantIdentity) return null; + return selectVoiceMediaGraphFailure(snapshot, { streamKey: target.streamKey, participantIdentity: target.participantIdentity, source: target.source, }); - return failure !== null; +} + +function selectFailureTileState( + failure: VoiceMediaGraphFailure, + recovery: VoiceMediaGraphStreamTileRecovery | undefined, +): VoiceMediaGraphStreamTileState { + if (!recovery) return 'failed'; + if (failure.code !== VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE.code) return 'failed'; + if (!recovery.hasRecoveryBudget) return 'failed'; + if (recovery.nowMs - failure.reportedAt >= VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS) return 'failed'; + return 'recovering'; } function tileIsRendering( @@ -48,11 +71,13 @@ function tileIsRendering( export function selectVoiceMediaGraphStreamTileState( snapshot: VoiceMediaGraphSnapshot, target: VoiceMediaGraphStreamTileTarget, + recovery?: VoiceMediaGraphStreamTileRecovery, ): VoiceMediaGraphStreamTileState { const entry = target.participantIdentity ? selectVoiceMediaGraphSubscriptionEntry(snapshot, target.participantIdentity, target.source) : null; - if (tileHasFailure(snapshot, target)) return 'failed'; + const failure = selectTileFailure(snapshot, target); + if (failure !== null) return selectFailureTileState(failure, recovery); if (tileIsRendering(snapshot, target, entry)) return 'rendering'; if (entry?.actual.lastError) return 'failed'; if (entry?.actual.subscribed === true) return 'subscribedAwaitingFrame';