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",