fix(voice): rebind codec negotiation after a region hot swap (#2662)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 12:55:50 +02:00
1 parent 74782dc4f2
commit baf2cbf3fd
2 files changed
+215 -5

No files matched your search

@@ -0,0 +1,197 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {RoomEventCallbacks, RoomEventDependencies} from '@app/features/voice/engine/VoiceRoomEventBinder';
import {ConnectionState, type Room, RoomEvent} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const mocks = vi.hoisted(() => ({
bindCodecNegotiation: vi.fn(() => vi.fn()),
disposeCodecNegotiation: vi.fn(),
bindScreenShareMigration: vi.fn(() => vi.fn()),
disposeScreenShareMigration: vi.fn(),
}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: () => null,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({
default: {
bind: mocks.bindCodecNegotiation,
dispose: mocks.disposeCodecNegotiation,
getSelectedCodec: vi.fn(() => null),
publishLocalCapabilities: vi.fn(async () => null),
selectNativeScreenShareCodec: vi.fn(() => null),
selectScreenShareCodec: vi.fn(() => null),
setSelectionChangeListener: vi.fn(),
},
}));
vi.mock('@app/features/voice/engine/ScreenSharePublicationMigration', () => ({
default: {
bind: mocks.bindScreenShareMigration,
dispose: mocks.disposeScreenShareMigration,
getManagedScreenSharePublications: vi.fn(() => []),
getScreenSharePublicationsToDisable: vi.fn(() => []),
isScreenShareBuffering: vi.fn(() => false),
selectScreenSharePublication: vi.fn(() => null),
subscribe: vi.fn(() => vi.fn()),
version: 0,
},
}));
installVoiceMenuTestBootstrap();
const {bindRoomEvents} = await import('@app/features/voice/engine/VoiceRoomEventBinder');
class FakeRoom {
private handlers = new Map<string, Set<(...args: Array<unknown>) => unknown>>();
localParticipant = {identity: 'local', on: vi.fn(), off: vi.fn()};
remoteParticipants = new Map<string, {identity: string}>();
constructor(public state: ConnectionState) {}
on(event: string, handler: (...args: Array<unknown>) => unknown): this {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)?.add(handler);
return this;
}
off(event: string, handler: (...args: Array<unknown>) => unknown): this {
this.handlers.get(event)?.delete(handler);
return this;
}
async emit(event: string, ...args: Array<unknown>): Promise<void> {
for (const handler of this.handlers.get(event) ?? []) {
await handler(...args);
}
}
}
function createCallbacks(): RoomEventCallbacks {
return {
onConnected: vi.fn(async () => undefined),
onDisconnected: vi.fn(),
onReconnecting: vi.fn(),
onReconnected: vi.fn(),
};
}
function createDependencies(options: {attemptIsStale?: boolean} = {}): RoomEventDependencies {
return {
connection: {
createGuardedHandler: (_attemptId, handler) => {
return (...args) => {
if (options.attemptIsStale) return;
void handler(...args);
};
},
isDisconnecting: () => false,
isUserMovePending: () => true,
markConnected: vi.fn(),
markDisconnected: vi.fn(),
markReconnecting: vi.fn(),
markReconnected: vi.fn(),
},
media: {
applyAllLocalAudioPreferences: vi.fn(),
ensureMicrophone: vi.fn(async () => undefined),
playEntranceSound: vi.fn(async () => undefined),
resetStreamTracking: vi.fn(),
},
mediaState: {
handleLocalTrackStateChange: vi.fn(() => false),
resetLocalMediaState: vi.fn(),
},
participants: {
clear: vi.fn(),
hydrateFromRoom: vi.fn(),
removeParticipant: vi.fn(),
updateActiveSpeakers: vi.fn(),
upsertParticipant: vi.fn(),
},
permissions: {
applyDeafen: vi.fn(),
syncWithPermissionState: vi.fn(),
},
remoteSpeaking: {
attachIfApplicable: vi.fn(),
clear: vi.fn(),
detachByIdentity: vi.fn(),
detachIfTrackMatches: vi.fn(),
hydrateFromRoom: vi.fn(),
},
screenShare: {
cleanupLingeringScreenShareTracks: vi.fn(async () => undefined),
handleLocalScreenShareTrackUnpublished: vi.fn(),
isScreenSharePublicationReplaceInFlight: () => false,
},
subscriptions: {
isScreenShareSubscribed: () => false,
reattachScreenShareAfterPublish: vi.fn(),
reconcileSubscriptions: vi.fn(),
},
};
}
function bindFakeRoom(room: FakeRoom, options: {attemptIsStale?: boolean} = {}): void {
bindRoomEvents(room as unknown as Room, 1, 'guild-1', 'channel-1', createCallbacks(), createDependencies(options));
}
describe('binding room events for screen share negotiation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('binds codec negotiation and publication migration to a room that is already connected', () => {
bindFakeRoom(new FakeRoom(ConnectionState.Connected));
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds neither until a room that is not connected yet reports it has connected', async () => {
const room = new FakeRoom(ConnectionState.Disconnected);
bindFakeRoom(room);
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
room.state = ConnectionState.Connected;
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds once however often an already connected room reports it has connected', async () => {
const room = new FakeRoom(ConnectionState.Connected);
bindFakeRoom(room);
await room.emit(RoomEvent.Connected);
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds again after an already connected room disconnects and connects once more', async () => {
const room = new FakeRoom(ConnectionState.Connected);
bindFakeRoom(room);
await room.emit(RoomEvent.Disconnected);
expect(mocks.disposeCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.disposeScreenShareMigration).toHaveBeenCalledTimes(1);
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(2);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(2);
});
it('binds nothing to an already connected room whose connection attempt is stale', () => {
bindFakeRoom(new FakeRoom(ConnectionState.Connected), {attemptIsStale: true});
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
});
});
@@ -29,7 +29,7 @@ import type {
RemoteTrackPublication,
Room,
} from 'livekit-client';
import {ParticipantEvent, RoomEvent, Track} from 'livekit-client';
import {ConnectionState, ParticipantEvent, RoomEvent, Track} from 'livekit-client';
const logger = new Logger('VoiceRoomEventBinder');
@@ -145,6 +145,7 @@ export function bindRoomEvents(
const remoteTrackLifecycleDisposers = new Map<string, () => void>();
let codecNegotiationDisposer: (() => void) | null = null;
let screenShareMigrationDisposer: (() => void) | null = null;
let screenShareNegotiationBound = false;
const remoteTrackLifecycleRoleFor = (pub: RemoteTrackPublication): 'remote-screen-share' | 'remote-camera' | null => {
if (pub.kind !== Track.Kind.Video) return null;
if (pub.source === Track.Source.ScreenShare) return 'remote-screen-share';
@@ -254,6 +255,17 @@ export function bindRoomEvents(
ScreenSharePublicationMigration.dispose();
screenShareMigrationDisposer = null;
};
const bindScreenShareNegotiation = (): void => {
if (screenShareNegotiationBound) return;
screenShareNegotiationBound = true;
bindCodecNegotiation();
bindScreenShareMigration();
};
const unbindScreenShareNegotiation = (): void => {
screenShareNegotiationBound = false;
unbindCodecNegotiation();
unbindScreenShareMigration();
};
const scheduleDecoderVerification = (track: RemoteTrack, pub: RemoteTrackPublication): void => {
if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return;
clearScreenShareDecoderVerification(pub.trackSid);
@@ -307,8 +319,7 @@ export function bindRoomEvents(
bindParticipantSpeakingEvents(room.localParticipant);
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
dependencies.remoteSpeaking.hydrateFromRoom(room);
bindCodecNegotiation();
bindScreenShareMigration();
bindScreenShareNegotiation();
dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf);
dependencies.connection.markConnected();
await callbacks.onConnected();
@@ -335,8 +346,7 @@ export function bindRoomEvents(
guard(attemptId, () => {
participantSpeakingDisposers.forEach(({dispose}) => dispose());
participantSpeakingDisposers.clear();
unbindCodecNegotiation();
unbindScreenShareMigration();
unbindScreenShareNegotiation();
clearAllScreenShareDecoderVerifications();
clearAllRemoteTrackLifecycleBindings();
dependencies.remoteSpeaking.clear();
@@ -610,4 +620,7 @@ export function bindRoomEvents(
}
}),
);
if (room.state === ConnectionState.Connected) {
guard(attemptId, bindScreenShareNegotiation)();
}
}