summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/parent/ext-backgroundPage.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/extensions/parent/ext-backgroundPage.js1116
1 files changed, 1116 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..155220c67a
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -0,0 +1,1116 @@
+/* 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,
+ promiseBackgroundViewLoaded,
+ watchExtensionWorkerContextLoaded,
+} = ExtensionParent;
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+ChromeUtils.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)
+);
+
+// Pref used in tests to assert background page state set to
+// stopped on an extension process crash.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "disableRestartPersistentAfterCrash",
+ "extensions.background.disableRestartPersistentAfterCrash",
+ false
+);
+
+// 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, SUSPENDING is not used.
+ *
+ * See BackgroundContextOwner for the exact relation.
+ */
+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 = promiseBackgroundViewLoaded(this.browser);
+ this.browser.fixupAndLoadURIString(this.url, {
+ triggeringPrincipal: extension.principal,
+ });
+
+ context = await contextPromise;
+ // NOTE: context can be null if the load failed.
+
+ 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 => {
+ // TODO bug 1844486: resolve and/or unwatch when startup is interrupted.
+ 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
+ );
+
+ // TODO bug 1844486: Confirm that a shutdown() call during the above or
+ // below `await` calls can interrupt build() without leaving a stray worker
+ // registration behind.
+
+ 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);
+ });
+ }
+}
+
+/**
+ * The BackgroundContextOwner is instantiated at most once per extension and
+ * tracks the state of the background context. State changes can be triggered
+ * by explicit calls to methods with the "setBgState" prefix, but also by the
+ * background context itself, e.g. via an extension process crash.
+ *
+ * This class identifies the following stages of interest:
+ *
+ * 1. Initially no active background, waiting for a signal to get started.
+ * - method: none (at constructor and after setBgStateStopped)
+ * - state: STOPPED
+ * - context: null
+ * 2. Parent-triggered background startup
+ * - method: setBgStateStarting
+ * - state: STARTING (was STOPPED)
+ * - context: null
+ * 3. Background context creation observed in parent
+ * - method: none (observed by ExtensionParent's recvCreateProxyContext)
+ * TODO: add method to observe and keep track of it sooner than stage 4.
+ * - state: STARTING
+ * - context: ProxyContextParent subclass (was null)
+ * 4. Parent-observed background startup completion
+ * - method: setBgStateRunning
+ * - state: RUNNING (was STARTING)
+ * - context: ProxyContextParent (was null)
+ * 5. Background context unloaded for any reason
+ * - method: setBgStateStopped
+ * TODO bug 1844217: This is only implemented for process crashes and
+ * intentionally triggered terminations, not navigations/reloads.
+ * When unloads happen due to navigations/reloads, context will be
+ * null but the state will still be RUNNING.
+ * - state: STOPPED (was STOPPED, STARTING, RUNNING or SUSPENDING)
+ * - context: null (was ProxyContextParent if stage 4 ran).
+ * - Continue at stage 1 if the extension has not shut down yet.
+ */
+class BackgroundContextOwner {
+ /**
+ * @property {BackgroundBuilder} backgroundBuilder
+ *
+ * The source of parent-triggered background state changes.
+ */
+ backgroundBuilder;
+
+ /**
+ * @property {Extension} [extension]
+ *
+ * The Extension associated with the background. This is always set and
+ * cleared at extension shutdown.
+ */
+ extension;
+
+ /**
+ * @property {BackgroundPage|BackgroundWorker} [bgInstance]
+ *
+ * The BackgroundClass instance responsible for creating the background
+ * context. This is set as soon as there is a desire to start a background,
+ * and cleared as soon as the background context is not wanted any more.
+ *
+ * This field is set iff extension.backgroundState is not STOPPED.
+ */
+ bgInstance = null;
+
+ /**
+ * @property {ExtensionPageContextParent|BackgroundWorkerContextParent} [context]
+ *
+ * The parent-side counterpart to a background context in a child. The value
+ * is a subclass of ProxyContextParent, which manages its own lifetime. The
+ * class is ultimately instantiated through bgInstance. It can be destroyed by
+ * bgInstance or externally (e.g. by the context itself or a process crash).
+ * The reference to the context is cleared as soon as the context is unloaded.
+ *
+ * This is currently set when the background has fully loaded. To access the
+ * background context before that, use |extension.backgroundContext|.
+ *
+ * This field is set when extension.backgroundState is RUNNING or SUSPENDING.
+ */
+ context = null;
+
+ /**
+ * @property {boolean} [canBePrimed]
+ *
+ * This property reflects whether persistent listeners can be primed. This
+ * means that `backgroundState` is `STOPPED` and the listeners haven't been
+ * primed yet. It is initially `true`, and set to `false` as soon as
+ * listeners are primed. It can become `true` again if `primeBackground` was
+ * skipped due to `shouldPrimeBackground` being `false`.
+ * NOTE: this flag is set for both event pages and persistent background pages.
+ */
+ canBePrimed = true;
+
+ /**
+ * @property {boolean} [shouldPrimeBackground]
+ *
+ * This property controls whether we should prime listeners. Under normal
+ * conditions, this should always be `true` but when too many crashes have
+ * occurred, we might have to disable process spawning, which would lead to
+ * this property being set to `false`.
+ */
+ shouldPrimeBackground = true;
+
+ get #hasEnteredShutdown() {
+ // This getter is just a small helper to make sure we always check for
+ // the extension shutdown being already initiated.
+ // Ordinarily the extension object is expected to be nullified from the
+ // onShutdown method, but extension.hasShutdown is set earlier and because
+ // the shutdown goes through some async steps there is a chance for other
+ // internals to be hit while the hasShutdown flag is set bug onShutdown
+ // not hit yet.
+ return this.extension.hasShutdown || Services.startup.shuttingDown;
+ }
+
+ /**
+ * @param {BackgroundBuilder} backgroundBuilder
+ * @param {Extension} extension
+ */
+ constructor(backgroundBuilder, extension) {
+ this.backgroundBuilder = backgroundBuilder;
+ this.extension = extension;
+ this.onExtensionProcessCrashed = this.onExtensionProcessCrashed.bind(this);
+ this.onApplicationInForeground = this.onApplicationInForeground.bind(this);
+ this.onExtensionEnableProcessSpawning =
+ this.onExtensionEnableProcessSpawning.bind(this);
+
+ extension.backgroundState = BACKGROUND_STATE.STOPPED;
+
+ extensions.on("extension-process-crash", this.onExtensionProcessCrashed);
+ extensions.on(
+ "extension-enable-process-spawning",
+ this.onExtensionEnableProcessSpawning
+ );
+ // We only defer handling extension process crashes for persistent
+ // background context.
+ if (extension.persistentBackground) {
+ extensions.on("application-foreground", this.onApplicationInForeground);
+ }
+ }
+
+ /**
+ * setBgStateStarting - right before the background context is initialized.
+ *
+ * @param {BackgroundWorker|BackgroundPage} bgInstance
+ */
+ setBgStateStarting(bgInstance) {
+ if (!this.extension) {
+ throw new Error(`Cannot start background after extension shutdown.`);
+ }
+ if (this.bgInstance) {
+ throw new Error(`Cannot start multiple background instances`);
+ }
+ this.extension.backgroundState = BACKGROUND_STATE.STARTING;
+ this.bgInstance = bgInstance;
+ // Often already false, except if we're waking due to a listener that was
+ // registered with isInStartup=true.
+ this.canBePrimed = false;
+ }
+
+ /**
+ * setBgStateRunning - when the background context has fully loaded.
+ *
+ * This method may throw if the background should no longer be active; if that
+ * is the case, the caller should make sure that the background is cleaned up
+ * by calling setBgStateStopped.
+ *
+ * @param {ExtensionPageContextParent|BackgroundWorkerContextParent} context
+ */
+ setBgStateRunning(context) {
+ if (!this.extension) {
+ // Caller should have checked this.
+ throw new Error(`Extension has shut down before startup completion.`);
+ }
+ if (this.context) {
+ // This can currently not happen - we set the context only once.
+ // TODO bug 1844217: Handle navigation (bug 1286083). For now, reject.
+ throw new Error(`Context already set before at startup completion.`);
+ }
+ if (!context) {
+ throw new Error(`Context not found at startup completion.`);
+ }
+ if (context.unloaded) {
+ throw new Error(`Context has unloaded before startup completion.`);
+ }
+ this.extension.backgroundState = BACKGROUND_STATE.RUNNING;
+ this.context = context;
+ context.callOnClose(this);
+
+ // When the background startup completes successfully, update the set of
+ // events that should be persisted.
+ EventManager.clearPrimedListeners(this.extension, true);
+
+ // This notification will be balanced in setBgStateStopped / close.
+ notifyBackgroundScriptStatus(this.extension.id, true);
+
+ this.extension.emit("background-script-started");
+ }
+
+ /**
+ * setBgStateStopped - when the background context has unloaded or should be
+ * unloaded. Regardless of the actual state at the entry of this method, upon
+ * returning the background is considered stopped.
+ *
+ * If the context was active at the time of the invocation, the actual unload
+ * of |this.context| is asynchronous as it may involve a round-trip to the
+ * child process.
+ *
+ * @param {boolean} [isAppShutdown]
+ */
+ setBgStateStopped(isAppShutdown) {
+ const backgroundState = this.extension.backgroundState;
+ if (this.context) {
+ this.context.forgetOnClose(this);
+ this.context = null;
+ // This is the counterpart to the notification in setBgStateRunning.
+ notifyBackgroundScriptStatus(this.extension.id, false);
+ }
+
+ // We only need to call clearPrimedListeners for states STOPPED and STARTING
+ // because setBgStateRunning clears all primed listeners when it switches
+ // from STARTING to RUNNING. Further, the only way to get primed listeners
+ // is by a primeListeners call, which only happens in the STOPPED state.
+ if (
+ backgroundState === BACKGROUND_STATE.STOPPED ||
+ backgroundState === BACKGROUND_STATE.STARTING
+ ) {
+ EventManager.clearPrimedListeners(this.extension, false);
+ }
+
+ // Ensure there is no backgroundTimer running
+ this.backgroundBuilder.clearIdleTimer();
+
+ const bgInstance = this.bgInstance;
+ if (bgInstance) {
+ this.bgInstance = null;
+ isAppShutdown ||= Services.startup.shuttingDown;
+ // bgInstance.shutdown() unloads the associated context, if any.
+ bgInstance.shutdown(isAppShutdown);
+ this.backgroundBuilder.onBgInstanceShutdown(bgInstance);
+ }
+
+ this.extension.backgroundState = BACKGROUND_STATE.STOPPED;
+ if (backgroundState === BACKGROUND_STATE.STARTING) {
+ this.extension.emit("background-script-aborted");
+ }
+
+ if (this.extension.hasShutdown) {
+ this.extension = null;
+ } else if (this.shouldPrimeBackground) {
+ // Prime again, so that a stopped background can always be revived when
+ // needed.
+ this.backgroundBuilder.primeBackground(false);
+ } else {
+ this.canBePrimed = true;
+ }
+ }
+
+ // Called by registration via context.callOnClose (if this.context is set).
+ close() {
+ // close() is called when:
+ // - background context unloads (without replacement context).
+ // - extension process crashes (without replacement context).
+ // - background context reloads (context likely replaced by new context).
+ // - background context navigates (context likely replaced by new context).
+ //
+ // When the background is gone without replacement, switch to STOPPED.
+ // TODO bug 1286083: Drop support for navigations.
+
+ // To fully maintain the state, we should call this.setBgStateStopped();
+ // But we cannot do that yet because that would close background pages upon
+ // reload and navigation, which would be a backwards-incompatible change.
+ // For now, we only do the bare minimum here.
+ //
+ // Note that once a navigation or reload starts, that the context is
+ // untracked. This is a pre-existing issue that we should fix later.
+ // TODO bug 1844217: Detect context replacement and update this.context.
+ if (this.context) {
+ this.context.forgetOnClose(this);
+ this.context = null;
+ // This is the counterpart to the notification in setBgStateRunning.
+ notifyBackgroundScriptStatus(this.extension.id, false);
+ }
+ }
+
+ restartPersistentBackgroundAfterCrash() {
+ const { extension } = this;
+ if (
+ this.#hasEnteredShutdown ||
+ // Ignore if the background state isn't the one expected to be set
+ // after a crash.
+ extension.backgroundState !== BACKGROUND_STATE.STOPPED ||
+ // Auto-restart persistent background scripts after crash disabled by prefs.
+ disableRestartPersistentAfterCrash
+ ) {
+ return;
+ }
+
+ // Persistent background pages are re-primed from setBgStateStopped when we
+ // are hitting a crash (if the threshold was not exceeded, otherwise they
+ // are going to be re-primed from onExtensionEnableProcessSpawning).
+ extension.emit("start-background-script");
+ }
+
+ onExtensionEnableProcessSpawning() {
+ if (this.#hasEnteredShutdown) {
+ return;
+ }
+
+ if (!this.canBePrimed) {
+ return;
+ }
+
+ // Allow priming again.
+ this.shouldPrimeBackground = true;
+ this.backgroundBuilder.primeBackground(false);
+
+ if (this.extension.persistentBackground) {
+ this.restartPersistentBackgroundAfterCrash();
+ }
+ }
+
+ onApplicationInForeground(eventName, data) {
+ if (
+ this.#hasEnteredShutdown ||
+ // Past the silent crash handling threashold.
+ data.processSpawningDisabled
+ ) {
+ return;
+ }
+
+ this.restartPersistentBackgroundAfterCrash();
+ }
+
+ onExtensionProcessCrashed(eventName, data) {
+ if (this.#hasEnteredShutdown) {
+ return;
+ }
+
+ // data.childID holds the process ID of the crashed extension process.
+ // For now, assume that there is only one, so clean up unconditionally.
+
+ this.shouldPrimeBackground = !data.processSpawningDisabled;
+
+ // We only need to clean up if a bgInstance has been created. Without it,
+ // there is only state in the parent process, not the child, and a crashed
+ // extension process doesn't affect us.
+ if (this.bgInstance) {
+ this.setBgStateStopped();
+ }
+
+ if (this.extension.persistentBackground) {
+ // Defer to when back in foreground and/or process spawning is explicitly re-enabled.
+ if (!data.appInForeground || data.processSpawningDisabled) {
+ return;
+ }
+
+ this.restartPersistentBackgroundAfterCrash();
+ }
+ }
+
+ // Called by ExtensionAPI.onShutdown (once).
+ onShutdown(isAppShutdown) {
+ // If a background context was active during extension shutdown, then
+ // close() was called before onShutdown, which clears |this.extension|.
+ // If the background has not fully started yet, then we have to clear here.
+ if (this.extension) {
+ this.setBgStateStopped(isAppShutdown);
+ }
+ extensions.off("extension-process-crash", this.onExtensionProcessCrashed);
+ extensions.off(
+ "extension-enable-process-spawning",
+ this.onExtensionEnableProcessSpawning
+ );
+ extensions.off("application-foreground", this.onApplicationInForeground);
+ }
+}
+
+/**
+ * BackgroundBuilder manages the creation and parent-triggered termination of
+ * the background context. Non-parent-triggered terminations are usually due to
+ * an external cause (e.g. crashes) and detected by BackgroundContextOwner.
+ *
+ * Because these external terminations can happen at any time, and the creation
+ * and suspension of the background context is async, the methods of this
+ * BackgroundBuilder class necessarily need to check the state of the background
+ * before proceeding with the operation (and abort + clean up as needed).
+ *
+ * The following interruptions are explicitly accounted for:
+ * - Extension shuts down.
+ * - Background unloads for any reason.
+ * - Another background instance starts in the meantime.
+ */
+class BackgroundBuilder {
+ constructor(extension) {
+ this.extension = extension;
+ this.backgroundContextOwner = new BackgroundContextOwner(this, extension);
+ }
+
+ async build() {
+ if (this.backgroundContextOwner.bgInstance) {
+ return;
+ }
+
+ let { extension } = this;
+ let { manifest } = extension;
+ extension.backgroundState = BACKGROUND_STATE.STARTING;
+
+ this.isWorker =
+ !!manifest.background.service_worker &&
+ WebExtensionPolicy.backgroundServiceWorkerEnabled;
+
+ let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage;
+
+ const bgInstance = new BackgroundClass(extension, manifest.background);
+ this.backgroundContextOwner.setBgStateStarting(bgInstance);
+ let context;
+ try {
+ context = await bgInstance.build();
+ } catch (e) {
+ Cu.reportError(e);
+ // If background startup gets interrupted (e.g. extension shutdown),
+ // bgInstance.shutdown() is called and backgroundContextOwner.bgInstance
+ // is cleared.
+ if (this.backgroundContextOwner.bgInstance === bgInstance) {
+ this.backgroundContextOwner.setBgStateStopped();
+ }
+ 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 (this.backgroundContextOwner.bgInstance !== bgInstance) {
+ // Background closed/restarted in the meantime.
+ return;
+ }
+
+ try {
+ this.backgroundContextOwner.setBgStateRunning(context);
+ } catch (e) {
+ Cu.reportError(e);
+ this.backgroundContextOwner.setBgStateStopped();
+ }
+ }
+
+ 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;
+ }
+
+ primeBackground(isInStartup = true) {
+ let { extension } = this;
+
+ if (this.backgroundContextOwner.bgInstance) {
+ // This should never happen. The need to prime listeners is mutually
+ // exclusive with the existence of a background instance.
+ throw new Error(`bgInstance exists before priming ${extension.id}`);
+ }
+
+ // 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 &&
+ // After we begin suspending the background, parent API calls from
+ // runtime.onSuspend listeners shouldn't cancel the suspension.
+ resetIdleDetails?.reason !== "parentApiCall"
+ ) {
+ 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";
+ return; // not break; because too frequent, see bug 1868960.
+ case "hasActiveNativeAppPorts":
+ category = "reset_nativeapp";
+ break;
+ case "hasActiveStreamFilter":
+ category = "reset_streamfilter";
+ break;
+ case "pendingListeners":
+ category = "reset_listeners";
+ break;
+ case "parentApiCall":
+ category = "reset_parentapicall";
+ return; // not break; because too frequent, see bug 1868960.
+ }
+
+ 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);
+
+ // TODO bug 1844488: terminateBackground should account for externally
+ // triggered background restarts. It does currently performs various
+ // backgroundState checks, but it is possible for the background to have
+ // been crashes or restarted in the meantime.
+ 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);
+
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ if (!this.isWorker) {
+ ExtensionTelemetry.eventPageIdleResult.histogramAdd({
+ extension,
+ category: "suspend",
+ });
+ }
+
+ this.backgroundContextOwner.setBgStateStopped(false);
+ };
+
+ EventManager.primeListeners(extension, isInStartup);
+ // Avoid setting the flag to false when called during extension startup.
+ if (!isInStartup) {
+ this.backgroundContextOwner.canBePrimed = false;
+ }
+
+ // TODO: start-background-script and background-script-event should be
+ // unregistered when build() starts or when the extension shuts down.
+ extension.once("start-background-script", async () => {
+ if (!this.extension || this.extension.hasShutdown) {
+ // 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.
+ // 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");
+ });
+ }
+
+ onBgInstanceShutdown(bgInstance) {
+ const { msSinceCreated } = bgInstance;
+ const { extension } = this;
+
+ // Emit an event for tests.
+ extension.emit("shutdown-background-script");
+
+ if (msSinceCreated) {
+ const now = msSinceProcessStartExcludingSuspend();
+ if (
+ now &&
+ // TODO(Bug 1790087): record similar telemetry for background service worker.
+ !(this.isWorker || extension.persistentBackground)
+ ) {
+ ExtensionTelemetry.eventPageRunningTime.histogramAdd({
+ extension,
+ value: now - msSinceCreated,
+ });
+ }
+ }
+ }
+}
+
+this.backgroundPage = class extends ExtensionAPI {
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+
+ // 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;
+ }
+
+ this.backgroundBuilder = new BackgroundBuilder(extension);
+
+ // 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");
+ });
+
+ this.backgroundBuilder.primeBackground();
+
+ // 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 (
+ extension.testNoDelayedStartup ||
+ extension.startupReason !== "APP_STARTUP" ||
+ extension.updateReason
+ ) {
+ // TODO bug 1543354: Avoid AsyncShutdown timeouts by removing the await
+ // here, at least for non-test situations.
+ await this.backgroundBuilder.build();
+
+ // The task in ExtensionParent.browserPaintedPromise below would be fully
+ // skipped because of the above build() that sets bgInstance. Return early
+ // so that it is obvious that the logic is skipped.
+ return;
+ }
+
+ ExtensionParent.browserStartupPromise.then(() => {
+ // Return early if the background has started in the meantime. This can
+ // happen if a primed listener (isInStartup) has been triggered.
+ if (
+ !this.backgroundBuilder ||
+ this.backgroundBuilder.backgroundContextOwner.bgInstance ||
+ !this.backgroundBuilder.backgroundContextOwner.canBePrimed
+ ) {
+ return;
+ }
+
+ // We either start the background page immediately, or fully prime for
+ // real.
+ this.backgroundBuilder.backgroundContextOwner.canBePrimed = false;
+
+ // 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);
+ }
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (this.backgroundBuilder) {
+ this.backgroundBuilder.backgroundContextOwner.onShutdown(isAppShutdown);
+ this.backgroundBuilder = null;
+ }
+ }
+};