From 89fb81e4e0851b023ca4bc238f6c006026989f1d Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 19 Aug 2026 15:31:55 +0000 Subject: [PATCH] fix(desktop): stop stale microphone test streams (#11309) --- .../dictation/MicrophoneSelector.test.tsx | 132 ++++++++++++++++++ .../settings/dictation/MicrophoneSelector.tsx | 22 ++- 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 ui/desktop/src/components/settings/dictation/MicrophoneSelector.test.tsx diff --git a/ui/desktop/src/components/settings/dictation/MicrophoneSelector.test.tsx b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.test.tsx new file mode 100644 index 000000000..f3d3e7d43 --- /dev/null +++ b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.test.tsx @@ -0,0 +1,132 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IntlTestWrapper } from '../../../i18n/test-utils'; +import { MicrophoneSelector } from './MicrophoneSelector'; + +type FakeStream = MediaStream & { + track: { stop: ReturnType }; +}; + +type PendingStream = { + stream: FakeStream; + promise: Promise; + resolve: () => void; +}; + +class MockAudioContext { + createMediaStreamSource = vi.fn(() => ({ connect: vi.fn() })); + createAnalyser = vi.fn(() => ({ + fftSize: 0, + frequencyBinCount: 128, + getByteTimeDomainData: vi.fn(), + })); + close = vi.fn(() => Promise.resolve()); +} + +const createPendingStream = (): PendingStream => { + const track = { stop: vi.fn() }; + const stream = { + track, + getTracks: () => [track], + } as unknown as FakeStream; + let resolvePromise: (stream: MediaStream) => void = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + stream, + promise, + resolve: () => resolvePromise(stream), + }; +}; + +const renderSelector = async () => { + const result = render(, { + wrapper: IntlTestWrapper, + }); + await screen.findByRole('button', { name: 'Test' }); + return result; +}; + +describe('MicrophoneSelector microphone lifecycle', () => { + let pendingStreams: PendingStream[]; + + beforeEach(() => { + pendingStreams = []; + vi.stubGlobal('AudioContext', MockAudioContext); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1) + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + enumerateDevices: vi + .fn() + .mockResolvedValue([ + { kind: 'audioinput', deviceId: 'mic1', label: 'Mic One', groupId: 'group1' }, + ]), + getUserMedia: vi.fn(() => { + const pendingStream = createPendingStream(); + pendingStreams.push(pendingStream); + return pendingStream.promise; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('stops every stream acquired by overlapping test activations', async () => { + await renderSelector(); + const button = screen.getByRole('button', { name: 'Test' }); + + fireEvent.click(button); + fireEvent.click(button); + + await act(async () => { + pendingStreams.forEach(({ resolve }) => resolve()); + await Promise.all(pendingStreams.map(({ promise }) => promise)); + }); + + const stopButton = screen.queryByRole('button', { name: 'Stop' }); + if (stopButton) fireEvent.click(stopButton); + + expect(pendingStreams.length).toBeGreaterThan(0); + pendingStreams.forEach(({ stream }) => expect(stream.track.stop).toHaveBeenCalledOnce()); + }); + + it('stops a stream that arrives after the user stops the test', async () => { + await renderSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + + await act(async () => { + pendingStreams[0].resolve(); + await pendingStreams[0].promise; + }); + + expect(pendingStreams[0].stream.track.stop).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: 'Test' })).toBeInTheDocument(); + }); + + it('stops a stream that arrives after unmount', async () => { + const { unmount } = await renderSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + unmount(); + + await act(async () => { + pendingStreams[0].resolve(); + await pendingStreams[0].promise; + }); + + expect(pendingStreams[0].stream.track.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx index cdfa6c5fe..0ae1d6763 100644 --- a/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx +++ b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx @@ -74,6 +74,7 @@ export const MicrophoneSelector = ({ const testCtxRef = useRef(null); const rafRef = useRef(0); const testTimerRef = useRef | null>(null); + const testGenerationRef = useRef(0); const enumerate = useCallback(async () => { try { @@ -103,6 +104,7 @@ export const MicrophoneSelector = ({ }; const stopTest = useCallback(() => { + testGenerationRef.current += 1; if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = 0; if (testTimerRef.current) clearTimeout(testTimerRef.current); @@ -117,6 +119,9 @@ export const MicrophoneSelector = ({ const startTest = async () => { stopTest(); + const generation = testGenerationRef.current; + setIsTesting(true); + try { const constraints: MediaTrackConstraints = { echoCancellation: true, @@ -128,6 +133,10 @@ export const MicrophoneSelector = ({ } const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints }); + if (testGenerationRef.current !== generation) { + stream.getTracks().forEach((track) => track.stop()); + return; + } testStreamRef.current = stream; const ctx = new AudioContext(); @@ -140,6 +149,8 @@ export const MicrophoneSelector = ({ const dataArray = new Uint8Array(analyser.frequencyBinCount); const poll = () => { + if (testGenerationRef.current !== generation) return; + analyser.getByteTimeDomainData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) { @@ -151,10 +162,13 @@ export const MicrophoneSelector = ({ rafRef.current = requestAnimationFrame(poll); }; - setIsTesting(true); rafRef.current = requestAnimationFrame(poll); - testTimerRef.current = setTimeout(stopTest, TEST_DURATION_MS); + testTimerRef.current = setTimeout(() => { + if (testGenerationRef.current === generation) stopTest(); + }, TEST_DURATION_MS); } catch (e) { + if (testGenerationRef.current !== generation) return; + console.error('Mic test failed:', e); stopTest(); } @@ -211,7 +225,9 @@ export const MicrophoneSelector = ({ value={selectedDeviceId ?? 'system_default'} onValueChange={(v) => onDeviceChange(v === 'system_default' ? null : v)} > - {intl.formatMessage(i18n.systemDefault)} + + {intl.formatMessage(i18n.systemDefault)} + {devices.map((device, i) => ( {getDeviceLabel(device, i)}