Skip to content

Commit

Permalink
Bug 1861445 - Add the runtime.onPerformanceWarning WebExtension event…
Browse files Browse the repository at this point in the history
… 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 w3c/webextensions#456

Differential Revision: https://phabricator.services.mozilla.com/D194708
  • Loading branch information
kzar committed Jan 29, 2024
1 parent 859effd commit 4996fdb
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 0 deletions.
3 changes: 3 additions & 0 deletions browser/components/extensions/test/browser/browser.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -455,6 +457,7 @@ https_first_disabled = true
skip-if = [
"debug",
"asan",
"tsan" # Bug 1874317
]

["browser_ext_tab_runtimeConnect.js"]
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
48 changes: 48 additions & 0 deletions toolkit/components/extensions/parent/ext-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions toolkit/components/extensions/schemas/runtime.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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."
}
}
}
]
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ let expectedBackgroundApis = [
"runtime.onConnectExternal",
"runtime.onInstalled",
"runtime.onMessageExternal",
"runtime.onPerformanceWarning",
"runtime.onStartup",
"runtime.onSuspend",
"runtime.onSuspendCanceled",
"runtime.onUpdateAvailable",
"runtime.openOptionsPage",
"runtime.reload",
"runtime.setUninstallURL",
"runtime.OnPerformanceWarningCategory",
"runtime.OnPerformanceWarningSeverity",
"theme.getCurrent",
"theme.onUpdated",
"types.LevelOfControl",
Expand Down

0 comments on commit 4996fdb

Please sign in to comment.