summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/parent/ext-backgroundPage.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/parent/ext-backgroundPage.js')
-rw-r--r--toolkit/components/extensions/parent/ext-backgroundPage.js690
1 files changed, 690 insertions, 0 deletions
diff --git a/toolkit/components/extensions/parent/ext-backgroundPage.js b/toolkit/components/extensions/parent/ext-backgroundPage.js
new file mode 100644
index 0000000000..725be65122
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -0,0 +1,690 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var {
+ HiddenExtensionPage,
+ promiseExtensionViewLoaded,
+ watchExtensionWorkerContextLoaded,
+} = ExtensionParent;
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "serviceWorkerManager", () => {
+ return Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "backgroundIdleTimeout",
+ "extensions.background.idle.timeout",
+ 30000,
+ null,
+ // Minimum 100ms, max 5min
+ delay => Math.min(Math.max(delay, 100), 5 * 60 * 1000)
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["DOMException"]);
+
+function notifyBackgroundScriptStatus(addonId, isRunning) {
+ // Notify devtools when the background scripts is started or stopped
+ // (used to show the current status in about:debugging).
+ const subject = { addonId, isRunning };
+ Services.obs.notifyObservers(subject, "extension:background-script-status");
+}
+
+// Same as nsITelemetry msSinceProcessStartExcludingSuspend but returns
+// undefined instead of throwing an extension.
+function msSinceProcessStartExcludingSuspend() {
+ let now;
+ try {
+ now = Services.telemetry.msSinceProcessStartExcludingSuspend();
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ return now;
+}
+
+/**
+ * Background Page state transitions:
+ *
+ * ------> STOPPED <-------
+ * | | |
+ * | v |
+ * | STARTING >------|
+ * | | |
+ * | v ^
+ * |----< RUNNING ----> SUSPENDING
+ * ^ v
+ * |------------|
+ *
+ * STARTING: The background is being built.
+ * RUNNING: The background is running.
+ * SUSPENDING: The background is suspending, runtime.onSuspend will be called.
+ * STOPPED: The background is not running.
+ *
+ * For persistent backgrounds, the SUSPENDING is not used.
+ */
+const BACKGROUND_STATE = {
+ STARTING: "starting",
+ RUNNING: "running",
+ SUSPENDING: "suspending",
+ STOPPED: "stopped",
+};
+
+// Responsible for the background_page section of the manifest.
+class BackgroundPage extends HiddenExtensionPage {
+ constructor(extension, options) {
+ super(extension, "background");
+
+ this.page = options.page || null;
+ this.isGenerated = !!options.scripts;
+
+ // Last background/event page created time (retrieved using
+ // Services.telemetry.msSinceProcessStartExcludingSuspend when the
+ // parent process proxy context has been created).
+ this.msSinceCreated = null;
+
+ if (this.page) {
+ this.url = this.extension.baseURI.resolve(this.page);
+ } else if (this.isGenerated) {
+ this.url = this.extension.baseURI.resolve(
+ "_generated_background_page.html"
+ );
+ }
+ }
+
+ async build() {
+ const { extension } = this;
+ ExtensionTelemetry.backgroundPageLoad.stopwatchStart(extension, this);
+
+ let context;
+ try {
+ await this.createBrowserElement();
+ if (!this.browser) {
+ throw new Error(
+ "Extension shut down before the background page was created"
+ );
+ }
+ extension._backgroundPageFrameLoader = this.browser.frameLoader;
+
+ extensions.emit("extension-browser-inserted", this.browser);
+
+ let contextPromise = promiseExtensionViewLoaded(this.browser);
+ this.browser.fixupAndLoadURIString(this.url, {
+ triggeringPrincipal: extension.principal,
+ });
+
+ context = await contextPromise;
+
+ this.msSinceCreated = msSinceProcessStartExcludingSuspend();
+
+ ExtensionTelemetry.backgroundPageLoad.stopwatchFinish(extension, this);
+ } catch (e) {
+ // Extension was down before the background page has loaded.
+ ExtensionTelemetry.backgroundPageLoad.stopwatchCancel(extension, this);
+ throw e;
+ }
+
+ return context;
+ }
+
+ shutdown() {
+ this.extension._backgroundPageFrameLoader = null;
+ super.shutdown();
+ }
+}
+
+// Responsible for the background.service_worker section of the manifest.
+class BackgroundWorker {
+ constructor(extension, options) {
+ this.extension = extension;
+ this.workerScript = options.service_worker;
+
+ if (!this.workerScript) {
+ throw new Error("Missing mandatory background.service_worker property");
+ }
+ }
+
+ get registrationInfo() {
+ const { principal } = this.extension;
+ return serviceWorkerManager.getRegistrationForAddonPrincipal(principal);
+ }
+
+ getWorkerInfo(descriptorId) {
+ return this.registrationInfo?.getWorkerByID(descriptorId);
+ }
+
+ validateWorkerInfoForContext(context) {
+ const { extension } = this;
+ if (!this.getWorkerInfo(context.workerDescriptorId)) {
+ throw new Error(
+ `ServiceWorkerInfo not found for ${extension.policy.debugName} contextId ${context.contextId}`
+ );
+ }
+ }
+
+ async build() {
+ const { extension } = this;
+ let context;
+ const contextPromise = new Promise(resolve => {
+ let unwatch = watchExtensionWorkerContextLoaded(
+ { extension, viewType: "background_worker" },
+ context => {
+ unwatch();
+ this.validateWorkerInfoForContext(context);
+ resolve(context);
+ }
+ );
+ });
+
+ // TODO(Bug 17228327): follow up to spawn the active worker for a previously installed
+ // background service worker.
+ await serviceWorkerManager.registerForAddonPrincipal(
+ this.extension.principal
+ );
+
+ context = await contextPromise;
+
+ await this.waitForActiveWorker();
+ return context;
+ }
+
+ shutdown(isAppShutdown) {
+ // All service worker registrations related to the extensions will be unregistered
+ // - when the extension is shutting down if the application is not also shutting down
+ // shutdown (in which case a previously registered service worker is expected to stay
+ // active across browser restarts).
+ // - when the extension has been uninstalled
+ if (!isAppShutdown) {
+ this.registrationInfo?.forceShutdown();
+ }
+ }
+
+ waitForActiveWorker() {
+ const { extension, registrationInfo } = this;
+ return new Promise((resolve, reject) => {
+ const resolveOnActive = () => {
+ if (
+ registrationInfo.activeWorker?.state ===
+ Ci.nsIServiceWorkerInfo.STATE_ACTIVATED
+ ) {
+ resolve();
+ return true;
+ }
+ return false;
+ };
+
+ const rejectOnUnregistered = () => {
+ if (registrationInfo.unregistered) {
+ reject(
+ new Error(
+ `Background service worker unregistered for "${extension.policy.debugName}"`
+ )
+ );
+ return true;
+ }
+ return false;
+ };
+
+ if (resolveOnActive() || rejectOnUnregistered()) {
+ return;
+ }
+
+ const listener = {
+ onChange() {
+ if (resolveOnActive() || rejectOnUnregistered()) {
+ registrationInfo.removeListener(listener);
+ }
+ },
+ };
+ registrationInfo.addListener(listener);
+ });
+ }
+}
+
+this.backgroundPage = class extends ExtensionAPI {
+ async build() {
+ if (this.bgInstance) {
+ return;
+ }
+
+ let { extension } = this;
+ let { manifest } = extension;
+ extension.backgroundState = BACKGROUND_STATE.STARTING;
+
+ this.isWorker = Boolean(manifest.background.service_worker);
+
+ let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage;
+
+ this.bgInstance = new BackgroundClass(extension, manifest.background);
+ let context;
+ try {
+ context = await this.bgInstance.build();
+ // Top level execution already happened, RUNNING is
+ // a touch after the fact.
+ if (context && this.extension) {
+ extension.backgroundState = BACKGROUND_STATE.RUNNING;
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ if (extension.persistentListeners) {
+ // Clear the primed listeners, but leave them persisted.
+ EventManager.clearPrimedListeners(extension, false);
+ }
+ extension.backgroundState = BACKGROUND_STATE.STOPPED;
+ extension.emit("background-script-aborted");
+ return;
+ }
+
+ if (context) {
+ // Wait until all event listeners registered by the script so far
+ // to be handled. We then set listenerPromises to null, which indicates
+ // to addListener that the background script has finished loading.
+ await Promise.all(context.listenerPromises);
+ context.listenerPromises = null;
+ }
+
+ if (extension.persistentListeners) {
+ // |this.extension| may be null if the extension was shut down.
+ // In that case, we still want to clear the primed listeners,
+ // but not update the persistent listeners in the startupData.
+ EventManager.clearPrimedListeners(extension, !!this.extension);
+ }
+
+ if (!context || !this.extension) {
+ extension.backgroundState = BACKGROUND_STATE.STOPPED;
+ extension.emit("background-script-aborted");
+ return;
+ }
+ if (!context.unloaded) {
+ notifyBackgroundScriptStatus(extension.id, true);
+ context.callOnClose({
+ close() {
+ notifyBackgroundScriptStatus(extension.id, false);
+ },
+ });
+ }
+
+ extension.emit("background-script-started");
+ }
+
+ observe(subject, topic, data) {
+ if (topic == "timer-callback") {
+ let { extension } = this;
+ this.clearIdleTimer();
+ extension?.terminateBackground();
+ }
+ }
+
+ clearIdleTimer() {
+ this.backgroundTimer?.cancel();
+ this.backgroundTimer = null;
+ }
+
+ resetIdleTimer() {
+ this.clearIdleTimer();
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(this, backgroundIdleTimeout, Ci.nsITimer.TYPE_ONE_SHOT);
+ this.backgroundTimer = timer;
+ }
+
+ async primeBackground(isInStartup = true) {
+ let { extension } = this;
+
+ if (this.bgInstance) {
+ Cu.reportError(`background script exists before priming ${extension.id}`);
+ }
+ this.bgInstance = null;
+
+ // When in PPB background pages all run in a private context. This check
+ // simply avoids an extraneous error in the console since the BaseContext
+ // will throw.
+ if (
+ PrivateBrowsingUtils.permanentPrivateBrowsing &&
+ !extension.privateBrowsingAllowed
+ ) {
+ return;
+ }
+
+ // Used by runtime messaging to wait for background page listeners.
+ let bgStartupPromise = new Promise(resolve => {
+ let done = () => {
+ extension.off("background-script-started", done);
+ extension.off("background-script-aborted", done);
+ extension.off("shutdown", done);
+ resolve();
+ };
+ extension.on("background-script-started", done);
+ extension.on("background-script-aborted", done);
+ extension.on("shutdown", done);
+ });
+
+ extension.promiseBackgroundStarted = () => {
+ return bgStartupPromise;
+ };
+
+ extension.wakeupBackground = () => {
+ if (extension.hasShutdown) {
+ return Promise.reject(
+ new Error(
+ "wakeupBackground called while the extension was already shutting down"
+ )
+ );
+ }
+ extension.emit("background-script-event");
+ // `extension.wakeupBackground` is set back to the original arrow function
+ // when the background page is terminated and `primeBackground` is called again.
+ extension.wakeupBackground = () => bgStartupPromise;
+ return bgStartupPromise;
+ };
+
+ let resetBackgroundIdle = (eventName, resetIdleDetails) => {
+ this.clearIdleTimer();
+ if (!this.extension || extension.persistentBackground) {
+ // Extension was already shut down or is persistent and
+ // does not idle timout.
+ return;
+ }
+ // TODO remove at an appropriate point in the future prior
+ // to general availability. There may be some racy conditions
+ // with idle timeout between an event starting and the event firing
+ // but we still want testing with an idle timeout.
+ if (
+ !Services.prefs.getBoolPref("extensions.background.idle.enabled", true)
+ ) {
+ return;
+ }
+
+ if (extension.backgroundState == BACKGROUND_STATE.SUSPENDING) {
+ extension.backgroundState = BACKGROUND_STATE.RUNNING;
+ // call runtime.onSuspendCanceled
+ extension.emit("background-script-suspend-canceled");
+ }
+ this.resetIdleTimer();
+
+ if (
+ eventName === "background-script-reset-idle" &&
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ !this.isWorker
+ ) {
+ // Record the reason for resetting the event page idle timeout
+ // in a idle result histogram, with the category set based
+ // on the reason for resetting (defaults to 'reset_other'
+ // if resetIdleDetails.reason is missing or not mapped into the
+ // telemetry histogram categories).
+ //
+ // Keep this in sync with the categories listed in Histograms.json
+ // for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT".
+ let category = "reset_other";
+ switch (resetIdleDetails?.reason) {
+ case "event":
+ category = "reset_event";
+ break;
+ case "hasActiveNativeAppPorts":
+ category = "reset_nativeapp";
+ break;
+ case "hasActiveStreamFilter":
+ category = "reset_streamfilter";
+ break;
+ case "pendingListeners":
+ category = "reset_listeners";
+ break;
+ }
+
+ ExtensionTelemetry.eventPageIdleResult.histogramAdd({
+ extension,
+ category,
+ });
+ }
+ };
+
+ // Listen for events from the EventManager
+ extension.on("background-script-reset-idle", resetBackgroundIdle);
+ // After the background is started, initiate the first timer
+ extension.once("background-script-started", resetBackgroundIdle);
+
+ extension.terminateBackground = async ({
+ ignoreDevToolsAttached = false,
+ disableResetIdleForTest = false, // Disable all reset idle checks for testing purpose.
+ } = {}) => {
+ await bgStartupPromise;
+ if (!this.extension || this.extension.hasShutdown) {
+ // Extension was already shut down.
+ return;
+ }
+ if (extension.backgroundState != BACKGROUND_STATE.RUNNING) {
+ return;
+ }
+
+ if (
+ !ignoreDevToolsAttached &&
+ ExtensionParent.DebugUtils.hasDevToolsAttached(extension.id)
+ ) {
+ extension.emit("background-script-suspend-ignored");
+ return;
+ }
+
+ // Similar to what happens in recent Chrome version for MV3 extensions, extensions non-persistent
+ // background scripts with a nativeMessaging port still open or a sendNativeMessage request still
+ // pending an answer are exempt from being terminated when the idle timeout expires.
+ // The motivation, as for the similar change that Chrome applies to MV3 extensions, is that using
+ // the native messaging API have already an higher barrier due to having to specify a native messaging
+ // host app in their manifest and the user also have to install the native app separately as a native
+ // application).
+ if (
+ !disableResetIdleForTest &&
+ extension.backgroundContext?.hasActiveNativeAppPorts
+ ) {
+ extension.emit("background-script-reset-idle", {
+ reason: "hasActiveNativeAppPorts",
+ });
+ return;
+ }
+
+ if (
+ !disableResetIdleForTest &&
+ extension.backgroundContext?.pendingRunListenerPromisesCount
+ ) {
+ extension.emit("background-script-reset-idle", {
+ reason: "pendingListeners",
+ pendingListeners:
+ extension.backgroundContext.pendingRunListenerPromisesCount,
+ });
+ // Clear the pending promises being tracked when we have reset the idle
+ // once because some where still pending, so that the pending listeners
+ // calls can reset the idle timer only once.
+ extension.backgroundContext.clearPendingRunListenerPromises();
+ return;
+ }
+
+ const childId = extension.backgroundContext?.childId;
+ if (
+ childId !== undefined &&
+ extension.hasPermission("webRequestBlocking") &&
+ (extension.manifestVersion <= 3 ||
+ extension.hasPermission("webRequestFilterResponse"))
+ ) {
+ // Ask to the background page context in the child process to check if there are
+ // StreamFilter instances active (e.g. ones with status "transferringdata" or "suspended",
+ // see StreamFilterStatus enum defined in StreamFilter.webidl).
+ // TODO(Bug 1748533): consider additional changes to prevent a StreamFilter that never gets to an
+ // inactive state from preventing an even page from being ever suspended.
+ const hasActiveStreamFilter =
+ await ExtensionParent.ParentAPIManager.queryStreamFilterSuspendCancel(
+ extension.backgroundContext.childId
+ ).catch(err => {
+ // an AbortError raised from the JSWindowActor is expected if the background page was already been
+ // terminated in the meantime, and so we only log the errors that don't match these particular conditions.
+ if (
+ extension.backgroundState == BACKGROUND_STATE.STOPPED &&
+ DOMException.isInstance(err) &&
+ err.name === "AbortError"
+ ) {
+ return false;
+ }
+ Cu.reportError(err);
+ return false;
+ });
+ if (!disableResetIdleForTest && hasActiveStreamFilter) {
+ extension.emit("background-script-reset-idle", {
+ reason: "hasActiveStreamFilter",
+ });
+ return;
+ }
+
+ // Return earlier if extension have started or completed its shutdown in the meantime.
+ if (
+ extension.backgroundState !== BACKGROUND_STATE.RUNNING ||
+ extension.hasShutdown
+ ) {
+ return;
+ }
+ }
+
+ extension.backgroundState = BACKGROUND_STATE.SUSPENDING;
+ this.clearIdleTimer();
+ // call runtime.onSuspend
+ await extension.emit("background-script-suspend");
+ // If in the meantime another event fired, state will be RUNNING,
+ // and if it was shutdown it will be STOPPED.
+ if (extension.backgroundState != BACKGROUND_STATE.SUSPENDING) {
+ return;
+ }
+ extension.off("background-script-reset-idle", resetBackgroundIdle);
+ this.onShutdown(false);
+
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ if (!this.isWorker) {
+ ExtensionTelemetry.eventPageIdleResult.histogramAdd({
+ extension,
+ category: "suspend",
+ });
+ }
+
+ EventManager.clearPrimedListeners(this.extension, false);
+ // Setup background startup listeners for next primed event.
+ await this.primeBackground(false);
+ };
+
+ // Persistent backgrounds are started immediately except during APP_STARTUP.
+ // Non-persistent backgrounds must be started immediately for new install or enable
+ // to initialize the addon and create the persisted listeners.
+ // updateReason is set when an extension is updated during APP_STARTUP.
+ if (
+ isInStartup &&
+ (extension.testNoDelayedStartup ||
+ extension.startupReason !== "APP_STARTUP" ||
+ extension.updateReason)
+ ) {
+ return this.build();
+ }
+
+ EventManager.primeListeners(extension, isInStartup);
+
+ extension.once("start-background-script", async () => {
+ if (!this.extension) {
+ // Extension was shut down. Don't build the background page.
+ // Primed listeners have been cleared in onShutdown.
+ return;
+ }
+ await this.build();
+ });
+
+ // There are two ways to start the background page:
+ // 1. If a primed event fires, then start the background page as
+ // soon as we have painted a browser window. Note that we have
+ // to touch browserPaintedPromise here to initialize the listener
+ // or else we can miss it if the event occurs after the first
+ // window is painted but before #2
+ // 2. After all windows have been restored on startup (see onManifestEntry).
+ extension.once("background-script-event", async () => {
+ await ExtensionParent.browserPaintedPromise;
+ extension.emit("start-background-script");
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ this.extension.backgroundState = BACKGROUND_STATE.STOPPED;
+ // Ensure there is no backgroundTimer running
+ this.clearIdleTimer();
+
+ if (this.bgInstance) {
+ const { msSinceCreated } = this.bgInstance;
+ this.bgInstance.shutdown(isAppShutdown);
+ this.bgInstance = null;
+
+ const { extension } = this;
+
+ // Emit an event for tests.
+ extension.emit("shutdown-background-script");
+
+ const now = msSinceProcessStartExcludingSuspend();
+ if (
+ msSinceCreated &&
+ now &&
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ !(this.isWorker || extension.persistentBackground)
+ ) {
+ ExtensionTelemetry.eventPageRunningTime.histogramAdd({
+ extension,
+ value: now - msSinceCreated,
+ });
+ }
+ } else {
+ EventManager.clearPrimedListeners(this.extension, false);
+ }
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ extension.backgroundState = BACKGROUND_STATE.STOPPED;
+
+ // runtime.onStartup event support. We listen for the first
+ // background startup then emit a first-run event.
+ extension.once("background-script-started", () => {
+ extension.emit("background-first-run");
+ });
+
+ await this.primeBackground();
+
+ ExtensionParent.browserStartupPromise.then(() => {
+ // Return early if the background was created in the first
+ // primeBackground call. This happens for install, upgrade, downgrade.
+ if (this.bgInstance) {
+ return;
+ }
+
+ // If there are no listeners for the extension that were persisted, we need to
+ // start the event page so they can be registered.
+ if (
+ extension.persistentBackground ||
+ !extension.persistentListeners?.size ||
+ // If runtime.onStartup has a listener and this is app_startup,
+ // start the extension so it will fire the event.
+ (extension.startupReason == "APP_STARTUP" &&
+ extension.persistentListeners?.get("runtime").has("onStartup"))
+ ) {
+ extension.emit("start-background-script");
+ } else {
+ // During startup we only prime startup blocking listeners. At
+ // this stage we need to prime all listeners for event pages.
+ EventManager.clearPrimedListeners(extension, false);
+ // Allow re-priming by deleting existing listeners.
+ extension.persistentListeners = null;
+ EventManager.primeListeners(extension, false);
+ }
+ });
+ }
+};