Source code

Revision control

Copy as Markdown

Other Tools

/* 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 any idle background timer is not running.
this.backgroundBuilder.idleManager.clearState();
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);
this.idleManager = new IdleManager(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();
}
}
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 = (event, { reason }) => {
if (
extension.backgroundState == BACKGROUND_STATE.SUSPENDING &&
// After we begin suspending the background, parent API calls from
// runtime.onSuspend listeners shouldn't cancel the suspension.
reason !== "parentapicall"
) {
extension.backgroundState = BACKGROUND_STATE.RUNNING;
extension.emit("background-script-suspend-canceled");
}
if (extension.backgroundState !== BACKGROUND_STATE.RUNNING) {
// If STOPPED (or STARTING), then there is no idle timer and we should
// not reset the timer, because that would actually start the timer that
// eventually calls terminateBackground(), see bug 1905505 for example.
//
// When at state STARTING, we expect the startup to complete soon which
// in turn will start the idleManager timer.
if (
extension.backgroundState === BACKGROUND_STATE.STOPPED &&
// Skip logging for "event" because it can currently be encountered in
// practice due to bug 1905504, as explained in bug 1905505.
reason !== "event"
) {
Cu.reportError(
`Background keepalive reset with reason "${reason}" failed for ${extension.id}, state stopped.`
);
}
return;
}
this.idleManager.resetTimer();
if (this.isWorker) {
// TODO(Bug 1790087): record similar telemetry for service workers.
return;
}
if (reason === "event" || reason === "parentapicall") {
// Bug 1868960: not recording these because too frequent.
return;
}
// Keep in sync with categories in WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT.
let KNOWN = ["nativeapp", "streamfilter", "listeners"];
ExtensionTelemetry.eventPageIdleResult.histogramAdd({
extension,
category: `reset_${KNOWN.includes(reason) ? reason : "other"}`,
});
};
let idleWaitUntil = (_, { promise, reason }) => {
if (extension.backgroundState === BACKGROUND_STATE.STOPPED) {
// Sanity check: do not start idleManager.waitUntil if we are already
// stopped. The purpose of waitUntil is to keep the background alive,
// but if it was already stopped we cannot revive. At worst, we could
// interfere with a future bgInstance.
Cu.reportError(
`Background keepalive with reason "${reason}" failed for ${extension.id}, state stopped.`
);
return;
}
this.idleManager.waitUntil(promise, reason);
};
if (!extension.persistentBackground) {
// 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", () => {
this.idleManager.resetTimer();
});
extension.on("background-script-idle-waituntil", idleWaitUntil);
}
// 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.
} = {}) => {
if (extension.backgroundState === BACKGROUND_STATE.STOPPED) {
Cu.reportError(
`Cannot terminate background of ${extension.id} because it was already stopped.`
);
// If we were to continue we'd wait until the next background startup
// and terminate immediately, which is undesired.
return;
}
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: "nativeapp" });
return;
}
if (
!disableResetIdleForTest &&
extension.backgroundContext?.pendingRunListenerPromisesCount
) {
extension.emit("background-script-reset-idle", {
reason: "listeners",
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: "streamfilter",
});
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.idleManager.clearState();
// 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);
extension.off("background-script-idle-waituntil", idleWaitUntil);
// 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,
});
}
}
}
}
/**
* Times the suspension of the background page, acts like a 3-state machine:
* - suspended (or uninitialized)
* - waiting for a timeout (now() < sleepTime)
* - waiting on a promise (keepAlive.size > 0)
*
* When suspended, backgroundState is STOPPED and IdleManager does not have any
* pending timers or termination requests. The clearState() in setBgStateStopped
* ensures this.
*
* When backgroundState is in STARTING, waitUntil() may be called to register a
* termination blocker since the blocker may be relevant at the next state
* transition, to RUNNING.
*
* When backgroundState is in RUNNING, resetTimer() can be called for the first
* time to start the countdown to termination. resetTimer() may be called
* repeatedly to postpone termination.
*
* Eventually, if the timer will fire and call terminateBackground(), which
* initiates the transition from RUNNING to SUSPENDING to STOPPED.
*/
var IdleManager = class IdleManager {
sleepTime = 0;
/** @type {nsITimer} */
timer = null;
/** @type {Map<promise, string>} */
keepAlive = new Map();
constructor(extension) {
this.extension = extension;
}
waitUntil(originalPromise, reason) {
// Wrap the passed in promise so that we can resolve our .finally() handler
// in clearState() below, while also not keeping the originalPromise alive.
let { promise, resolve } = Promise.withResolvers();
originalPromise.finally(() => resolve());
let start = Cu.now();
this.keepAlive.set(promise, { reason, resolve });
promise.finally(() => {
if (
this.keepAlive.delete(promise) &&
!this.keepAlive.size &&
this.extension.backgroundState === BACKGROUND_STATE.RUNNING
) {
this.resetTimer();
}
if (Cu.now() - start > backgroundIdleTimeout) {
ExtensionTelemetry.eventPageIdleResult.histogramAdd({
extension: this.extension,
category: reason,
value: Math.round((Cu.now() - start) / backgroundIdleTimeout),
});
}
});
}
clearState() {
for (let { resolve } of this.keepAlive.values()) {
resolve();
}
this.keepAlive.clear();
this.clearTimer();
}
clearTimer() {
this.timer?.cancel();
this.timer = null;
}
resetTimer() {
this.sleepTime = Cu.now() + backgroundIdleTimeout;
if (!this.timer) {
this.createTimer();
}
}
createTimer() {
let timeLeft = this.sleepTime - Cu.now();
this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
this.timer.init(() => this.timeout(), timeLeft, Ci.nsITimer.TYPE_ONE_SHOT);
}
timeout() {
this.clearTimer();
if (!this.keepAlive.size) {
if (Cu.now() < this.sleepTime) {
this.createTimer();
} else {
// As explained in the comment before the IdleManager class, the timer
// is only scheduled while in the RUNNING state. Whenever the background
// transitions to another state (SUSPENDING or STOPPED), the timer is
// canceled and we won't reach this point.
this.extension.terminateBackground();
}
}
}
};
this.backgroundPage = class extends ExtensionAPI {
async onManifestEntry() {
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;
}
}
};