From 5fcb34af130fca5287e2386ab91b94175bbc1189 Mon Sep 17 00:00:00 2001 From: Dave Vandyke Date: Mon, 29 Jan 2024 10:32:09 +0000 Subject: [PATCH] Bug 1861445 - Add the runtime.onPerformanceWarning WebExtension event r=zombie,robwu When an extension's content script is very slow and causes a webpage to hang noticeably, a warning banner is displayed to the user. It would be useful to also notify the extension developer when that happens, so that they can address the issue. Let's add a new event runtime.onPerformanceWarning that can be dispatched when the browser needs to warn an extension of runtime performance issues. For now, let's just dispatch that event when the slow extension warning banner is displayed to the user. See also https://github.com/w3c/webextensions/issues/456 Differential Revision: https://phabricator.services.mozilla.com/D194708 --- .../extensions/test/browser/browser.toml | 3 + ...rowser_ext_runtime_onPerformanceWarning.js | 144 ++++++++++++++++++ .../extensions/parent/ext-runtime.js | 48 ++++++ .../extensions/schemas/runtime.json | 42 +++++ .../test/mochitest/test_ext_all_apis.js | 3 + 5 files changed, 240 insertions(+) create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml index c219c4f17d656..31768424228ba 100644 --- a/browser/components/extensions/test/browser/browser.toml +++ b/browser/components/extensions/test/browser/browser.toml @@ -381,6 +381,8 @@ run-if = ["crashreporter"] ["browser_ext_request_permissions.js"] +["browser_ext_runtime_onPerformanceWarning.js"] + ["browser_ext_runtime_openOptionsPage.js"] ["browser_ext_runtime_openOptionsPage_uninstall.js"] @@ -455,6 +457,7 @@ https_first_disabled = true skip-if = [ "debug", "asan", + "tsan" # Bug 1874317 ] ["browser_ext_tab_runtimeConnect.js"] diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js new file mode 100644 index 0000000000000..b0f62e677a651 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js @@ -0,0 +1,144 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { + Management: { + global: { tabTracker }, + }, +} = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +const { + ExtensionUtils: { promiseObserved }, +} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); + +class TestHangReport { + constructor(addonId, scriptBrowser) { + this.addonId = addonId; + this.scriptBrowser = scriptBrowser; + this.QueryInterface = ChromeUtils.generateQI(["nsIHangReport"]); + } + + userCanceled() {} + terminateScript() {} + + isReportForBrowserOrChildren(frameLoader) { + return ( + !this.scriptBrowser || this.scriptBrowser.frameLoader === frameLoader + ); + } +} + +function dispatchHangReport(extensionId, scriptBrowser) { + const hangObserved = promiseObserved("process-hang-report"); + + Services.obs.notifyObservers( + new TestHangReport(extensionId, scriptBrowser), + "process-hang-report" + ); + + return hangObserved; +} + +function background() { + let onPerformanceWarningDetails = null; + + browser.runtime.onPerformanceWarning.addListener(details => { + onPerformanceWarningDetails = details; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-performance-warning-details") { + browser.test.sendMessage( + "on-performance-warning-details", + onPerformanceWarningDetails + ); + onPerformanceWarningDetails = null; + } + }); +} + +async function expectOnPerformanceWarningDetails( + extension, + expectedOnPerformanceWarningDetails +) { + extension.sendMessage("get-on-performance-warning-details"); + + let actualOnPerformanceWarningDetails = await extension.awaitMessage( + "on-performance-warning-details" + ); + Assert.deepEqual( + actualOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails + ? "runtime.onPerformanceWarning fired with correct details" + : "runtime.onPerformanceWarning didn't fire" + ); +} + +add_task(async function test_should_fire_on_process_hang_report() { + const description = + "Slow extension content script caused a page hang, user was warned."; + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + + const notificationPromise = BrowserTestUtils.waitForGlobalNotificationBar( + window, + "process-hang" + ); + + const tabs = await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser), + BrowserTestUtils.openNewForegroundTab(gBrowser), + ]); + + // Warning event shouldn't have fired initially. + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and first tab. Warning event with first + // tab ID expected. + await dispatchHangReport(extension.id, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[0]), + }); + + // Hang report fired for different extension, no warning event expected. + await dispatchHangReport("wrong-addon-id", tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Non-extension hang report fired, no warning event expected. + await dispatchHangReport(null, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and second tab. Warning event with + // second tab ID expected. + await dispatchHangReport(extension.id, tabs[1].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[1]), + }); + + // Hang report fired for the extension with no associated tab. Warning event + // with no tab ID expected. + await dispatchHangReport(extension.id, null); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + }); + + await Promise.all(tabs.map(BrowserTestUtils.removeTab)); + await extension.unload(); + + // Wait for the process-hang warning bar to be displayed, then ensure it's + // cleared to avoid clobbering other tests. + const notification = await notificationPromise; + Assert.ok(notification.isConnected, "Notification still present"); + notification.buttonContainer.querySelector("[label='Stop']").click(); +}); diff --git a/toolkit/components/extensions/parent/ext-runtime.js b/toolkit/components/extensions/parent/ext-runtime.js index ead070ac27f58..f4f9ea6616f29 100644 --- a/toolkit/components/extensions/parent/ext-runtime.js +++ b/toolkit/components/extensions/parent/ext-runtime.js @@ -4,6 +4,10 @@ "use strict"; +// This file expects tabTracker to be defined in the global scope (e.g. +// by ext-browser.js or ext-android.js). +/* global tabTracker */ + var { ExtensionParent } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionParent.sys.mjs" ); @@ -83,6 +87,43 @@ this.runtime = class extends ExtensionAPIPersistent { }, }; }, + onPerformanceWarning({ fire }) { + let { extension } = this; + + let observer = (subject, topic) => { + let report = subject.QueryInterface(Ci.nsIHangReport); + + if (report?.addonId !== extension.id) { + return; + } + + const performanceWarningEventDetails = { + category: "content_script", + severity: "high", + description: + "Slow extension content script caused a page hang, user was warned.", + }; + + let scriptBrowser = report.scriptBrowser; + let nativeTab = + scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser); + if (nativeTab) { + performanceWarningEventDetails.tabId = tabTracker.getId(nativeTab); + } + + fire.async(performanceWarningEventDetails); + }; + + Services.obs.addObserver(observer, "process-hang-report"); + return { + unregister: () => { + Services.obs.removeObserver(observer, "process-hang-report"); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, }; getAPI(context) { @@ -171,6 +212,13 @@ this.runtime = class extends ExtensionAPIPersistent { }, }).api(), + onPerformanceWarning: new EventManager({ + context, + module: "runtime", + event: "onPerformanceWarning", + extensionApi: this, + }).api(), + reload: async () => { if (extension.upgrade) { // If there is a pending update, install it now. diff --git a/toolkit/components/extensions/schemas/runtime.json b/toolkit/components/extensions/schemas/runtime.json index 5f894fa0e9740..84ef7d3ccbaf1 100644 --- a/toolkit/components/extensions/schemas/runtime.json +++ b/toolkit/components/extensions/schemas/runtime.json @@ -162,6 +162,18 @@ "allowedContexts": ["content", "devtools"], "description": "The reason that the event is being dispatched. 'app_update' is used when the restart is needed because the application is updated to a newer version. 'os_update' is used when the restart is needed because the browser/OS is updated to a newer version. 'periodic' is used when the system runs for more than the permitted uptime set in the enterprise policy.", "enum": ["app_update", "os_update", "periodic"] + }, + { + "id": "OnPerformanceWarningCategory", + "type": "string", + "enum": ["content_script"], + "description": "The performance warning event category, e.g. 'content_script'." + }, + { + "id": "OnPerformanceWarningSeverity", + "type": "string", + "enum": ["low", "medium", "high"], + "description": "The performance warning event severity. Will be 'high' for serious and user-visible issues." } ], "properties": { @@ -677,6 +689,36 @@ "description": "The reason that the event is being dispatched." } ] + }, + { + "name": "onPerformanceWarning", + "type": "function", + "description": "Fired when a runtime performance issue is detected with the extension. Observe this event to be proactively notified of runtime performance problems with the extension.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "category": { + "$ref": "OnPerformanceWarningCategory", + "description": "The performance warning event category, e.g. 'content_script'." + }, + "severity": { + "$ref": "OnPerformanceWarningSeverity", + "description": "The performance warning event severity, e.g. 'high'." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "The $(ref:tabs.Tab) that the performance warning relates to, if any." + }, + "description": { + "type": "string", + "description": "An explanation of what the warning means, and hopefully how to address it." + } + } + } + ] } ] } diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js index 4c82a4575cf83..95ac9af50df08 100644 --- a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js +++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js @@ -95,6 +95,7 @@ let expectedBackgroundApis = [ "runtime.onConnectExternal", "runtime.onInstalled", "runtime.onMessageExternal", + "runtime.onPerformanceWarning", "runtime.onStartup", "runtime.onSuspend", "runtime.onSuspendCanceled", @@ -102,6 +103,8 @@ let expectedBackgroundApis = [ "runtime.openOptionsPage", "runtime.reload", "runtime.setUninstallURL", + "runtime.OnPerformanceWarningCategory", + "runtime.OnPerformanceWarningSeverity", "theme.getCurrent", "theme.onUpdated", "types.LevelOfControl",