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:
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.