tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 022c942d407905cdadaefa821ff4aee0f8069140
parent 25c021cb2768368ec1b6fabfce47e8d6c2579756
Author: Emma Zuehlcke <emz@mozilla.com>
Date:   Wed,  7 Jan 2026 12:33:55 +0000

Bug 2007284 - Properly clean up webrtc previw video stream when preview is closed while loading. r=karlt

Differential Revision: https://phabricator.services.mozilla.com/D278011

Diffstat:
Mbrowser/app/profile/firefox.js | 4++++
Mbrowser/base/content/test/webrtc/browser.toml | 5+++++
Abrowser/base/content/test/webrtc/browser_devices_get_user_media_camera_preview_abort.js | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/webrtc/content/webrtc-preview/webrtc-preview.mjs | 49++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 205 insertions(+), 7 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -2631,6 +2631,10 @@ pref("privacy.webrtc.deviceGracePeriodTimeoutMs", 3600000); // the pref to hide the icons pref("privacy.webrtc.showIndicatorsOnMacos14AndAbove", true); +// Testing pref: adds artificial delay (in ms) to gUM requests in webrtc-preview. +// Used for testing abort logic. 0 means no delay. +pref("privacy.webrtc.preview.testGumDelayMs", 0); + // Enable Smartblock embed placeholders pref("extensions.webcompat.smartblockEmbeds.enabled", true); diff --git a/browser/base/content/test/webrtc/browser.toml b/browser/base/content/test/webrtc/browser.toml @@ -59,6 +59,11 @@ skip-if = [ "os == 'mac' && os_version == '15.30' && arch == 'aarch64'", # Bug 1989731 ] +["browser_devices_get_user_media_camera_preview_abort.js"] +skip-if = [ + "os == 'mac' && os_version == '15.30' && arch == 'aarch64'", # Bug 1989731 +] + ["browser_devices_get_user_media_default_permissions.js"] https_first_disabled = true skip-if = [ diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_camera_preview_abort.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_camera_preview_abort.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "https://example.com/" +); +const TEST_PAGE = TEST_ROOT + "get_user_media.html"; + +const PREF_GUM_DELAY = "privacy.webrtc.preview.testGumDelayMs"; + +/** + * Test that stopping preview while gUM is pending doesn't leak the stream. + * See Bug 2007284. + */ +add_task(async function test_stop_preview_during_pending_gum() { + const GUM_DELAY_MS = 500; + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_PERMISSION_FAKE, true], + [PREF_AUDIO_LOOPBACK, ""], + [PREF_VIDEO_LOOPBACK, ""], + [PREF_FAKE_STREAMS, true], + [PREF_FOCUS_SOURCE, false], + [PREF_GUM_DELAY, GUM_DELAY_MS], + ], + }); + + await BrowserTestUtils.withNewTab(TEST_PAGE, async () => { + let promise = promisePopupNotificationShown("webRTC-shareDevices"); + let observerPromise = expectObserverCalled("getUserMedia:request"); + + await promiseRequestDevice(true, true); + await promise; + await observerPromise; + + let webRTCPreviewEl = document.getElementById("webRTC-preview"); + ok(BrowserTestUtils.isVisible(webRTCPreviewEl), "preview is visible"); + + let videoEl = webRTCPreviewEl.shadowRoot.querySelector("video"); + ok(videoEl, "video element exists"); + + let loadingIndicator = + webRTCPreviewEl.shadowRoot.querySelector("#loading-indicator"); + let showPreviewButton = webRTCPreviewEl.shadowRoot.querySelector( + "#show-preview-button" + ); + let stopPreviewButton = webRTCPreviewEl.shadowRoot.querySelector( + "#stop-preview-button" + ); + + let completePromise = BrowserTestUtils.waitForEvent( + webRTCPreviewEl, + "test-preview-complete" + ); + + info("Start the preview (gUM will be delayed)"); + showPreviewButton.click(); + + info("Wait for gUM to be called (loading indicator visible)"); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(loadingIndicator), + "loading indicator should be visible" + ); + + info("Stop the preview while gUM is still pending"); + stopPreviewButton.click(); + + info("Wait for the delayed gUM to complete"); + let event = await completePromise; + is(event.detail.result, "aborted", "preview was aborted"); + + is( + videoEl.srcObject, + null, + "video srcObject should still be null after aborted gUM completes" + ); + ok(videoEl.paused, "video should be paused"); + + info("Close permission prompt"); + observerPromise = expectObserverCalled("getUserMedia:response:deny"); + activateSecondaryAction(kActionDeny); + await observerPromise; + }); +}); + +/** + * Test that closing the popup (disconnecting the element) while gUM is pending + * doesn't leak the stream. See Bug 2007284. + */ +add_task(async function test_close_popup_during_pending_gum() { + const GUM_DELAY_MS = 500; + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_PERMISSION_FAKE, true], + [PREF_AUDIO_LOOPBACK, ""], + [PREF_VIDEO_LOOPBACK, ""], + [PREF_FAKE_STREAMS, true], + [PREF_FOCUS_SOURCE, false], + [PREF_GUM_DELAY, GUM_DELAY_MS], + ], + }); + + await BrowserTestUtils.withNewTab(TEST_PAGE, async () => { + let promise = promisePopupNotificationShown("webRTC-shareDevices"); + let observerPromise = expectObserverCalled("getUserMedia:request"); + + await promiseRequestDevice(true, true); + await promise; + await observerPromise; + + let webRTCPreviewEl = document.getElementById("webRTC-preview"); + ok(BrowserTestUtils.isVisible(webRTCPreviewEl), "preview is visible"); + + let videoEl = webRTCPreviewEl.shadowRoot.querySelector("video"); + + let loadingIndicator = + webRTCPreviewEl.shadowRoot.querySelector("#loading-indicator"); + let showPreviewButton = webRTCPreviewEl.shadowRoot.querySelector( + "#show-preview-button" + ); + + let completePromise = BrowserTestUtils.waitForEvent( + webRTCPreviewEl, + "test-preview-complete" + ); + + info("Start the preview (gUM will be delayed)"); + showPreviewButton.click(); + + info("Wait for gUM to be called (loading indicator visible)"); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(loadingIndicator), + "loading indicator should be visible" + ); + + info("Close the popup while gUM is still pending"); + observerPromise = expectObserverCalled("getUserMedia:response:deny"); + activateSecondaryAction(kActionDeny); + await observerPromise; + + info("Wait for the delayed gUM to complete"); + let event = await completePromise; + is(event.detail.result, "aborted", "preview was aborted"); + + is( + videoEl.srcObject, + null, + "video srcObject should still be null after aborted gUM completes" + ); + }); +}); diff --git a/browser/components/webrtc/content/webrtc-preview/webrtc-preview.mjs b/browser/components/webrtc/content/webrtc-preview/webrtc-preview.mjs @@ -5,6 +5,18 @@ import { html, classMap } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "testGumDelayMs", + "privacy.webrtc.preview.testGumDelayMs", + 0 +); + window.MozXULElement?.insertFTLIfNeeded("browser/webrtc-preview.ftl"); /** @@ -30,6 +42,8 @@ export class WebRTCPreview extends MozLitElement { // The stream object for the preview. Only set when the preview is active. #stream = null; + // AbortController to cancel pending gUM requests when stopping preview. + #abortController = null; constructor() { super(); @@ -88,6 +102,9 @@ export class WebRTCPreview extends MozLitElement { // Stop any existing preview. this.stopPreview(); + this.#abortController = new AbortController(); + let { signal } = this.#abortController; + this._loading = true; this._previewActive = true; @@ -103,11 +120,17 @@ export class WebRTCPreview extends MozLitElement { }; let stream; - let currentDeviceId = this.deviceId; try { stream = await navigator.mediaDevices.getUserMedia(constraints); + if (lazy.testGumDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, lazy.testGumDelayMs)); + } } catch (error) { + if (signal.aborted) { + this.#dispatchTestEvent("aborted"); + return; + } this._loading = false; if ( error.name == "OverconstrainedError" && @@ -116,29 +139,41 @@ export class WebRTCPreview extends MozLitElement { // Source has disappeared since enumeration, which can happen. // No preview. this.stopPreview(); + this.#dispatchTestEvent("error"); return; } console.error(`error in preview: ${error.message} ${error.constraint}`); + this.#dispatchTestEvent("error"); + return; } - if (this.deviceId != currentDeviceId) { - this._loading = false; - // If the deviceId changed while we were waiting for gUM, e.g. because the user selected a different device, restart the preview. + if (signal.aborted) { stream.getTracks().forEach(t => t.stop()); - this.startPreview(); + this.#dispatchTestEvent("aborted"); return; } this.videoEl.srcObject = stream; this.#stream = stream; + this.#dispatchTestEvent("success"); + } + + #dispatchTestEvent(result) { + if (lazy.testGumDelayMs > 0) { + this.dispatchEvent( + new CustomEvent("test-preview-complete", { detail: { result } }) + ); + } } /** * Stop the preview. */ stopPreview() { - // We might interrupt an in-progress load. Make sure we don't show a loading - // state. + // Abort any pending gUM request. + this.#abortController?.abort(); + this.#abortController = null; + this._loading = false; // Stop any existing playback.