From 407ec21c6d3ae9cd51e43fd04468ab4a5d14ea33 Mon Sep 17 00:00:00 2001 From: Dave Vandyke Date: Thu, 4 Jan 2024 04:18:27 +0000 Subject: [PATCH] Bug 1861445 - Add the runtime.onPerformanceWarning WebExtension event r=zombie 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 | 2 + ...rowser_ext_runtime_onPerformanceWarning.js | 125 ++++++++++++++++++ .../extensions/parent/ext-runtime.js | 48 +++++++ .../extensions/schemas/runtime.json | 42 ++++++ .../test/mochitest/test_ext_all_apis.js | 3 + 5 files changed, 220 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 3301483a6d9be..bbd773c01d7fd 100644 --- a/browser/components/extensions/test/browser/browser.toml +++ b/browser/components/extensions/test/browser/browser.toml @@ -382,6 +382,8 @@ skip-if = ["!crashreporter"] ["browser_ext_request_permissions.js"] +["browser_ext_runtime_onPerformanceWarning.js"] + ["browser_ext_runtime_openOptionsPage.js"] ["browser_ext_runtime_openOptionsPage_uninstall.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..cd2fb3237aa9b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js @@ -0,0 +1,125 @@ +/* -*- 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"]); + } +} + +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 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(); +}); 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",