1210 lines
42 KiB
JavaScript
1210 lines
42 KiB
JavaScript
/* 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;
|
|
|
|
let BackgroundClass = extension.workerBackground
|
|
? 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 (extension.workerBackground) {
|
|
// 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 (!extension.workerBackground) {
|
|
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.
|
|
!(extension.workerBackground || 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) {
|
|
let value = Math.round((Cu.now() - start) / backgroundIdleTimeout);
|
|
// GIFFT doesn't support mirroring to a categorical histogram with
|
|
// values that are not 1, so do the histogramAdd call as many times
|
|
// as needed. It will be once most of the time, sometimes twice.
|
|
while (value--) {
|
|
ExtensionTelemetry.eventPageIdleResult.histogramAdd({
|
|
extension: this.extension,
|
|
category: reason,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
};
|