/* 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;
    }
  }
};
