Bug 1450388 Part 3: Implement persistent option for EventManager draft
authorAndrew Swan <aswan@mozilla.com>
Fri, 30 Mar 2018 16:38:42 -0700
changeset 779300 5f0d554a44dbd247877ef0cc3b854bcd20781066
parent 779299 dc26174a27d44bd6badaa653abfebe69ffa2a160
push id105738
push useraswan@mozilla.com
push dateMon, 09 Apr 2018 17:23:03 +0000
bugs1450388
milestone61.0a1
Bug 1450388 Part 3: Implement persistent option for EventManager MozReview-Commit-ID: BtccacvzhE8
modules/libpref/init/all.js
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/parent/.eslintrc.js
toolkit/components/extensions/parent/ext-backgroundPage.js
toolkit/components/extensions/parent/ext-toolkit.js
toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5040,16 +5040,18 @@ pref("extensions.webextensions.remote", 
 // Whether or not the moz-extension resource loads are remoted. For debugging
 // purposes only. Setting this to false will break moz-extension URI loading
 // unless other process sandboxing and extension remoting prefs are changed.
 pref("extensions.webextensions.protocol.remote", true);
 
 // Disable tab hiding API by default.
 pref("extensions.webextensions.tabhide.enabled", false);
 
+pref("extensions.webextensions.background-delayed-startup", false);
+
 // Report Site Issue button
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if defined(MOZ_DEV_EDITION) || defined(NIGHTLY_BUILD)
 pref("extensions.webcompat-reporter.enabled", true);
 #else
 pref("extensions.webcompat-reporter.enabled", false);
 #endif
 
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -16,16 +16,17 @@
 var EXPORTED_SYMBOLS = ["ExtensionCommon"];
 
 Cu.importGlobalProperties(["fetch"]);
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
+  AppConstants: "resource://gre/modules/AppConstants.jsm",
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
   SchemaRoot: "resource://gre/modules/Schemas.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService",
@@ -1768,32 +1769,196 @@ class EventManager {
    *        An object representing the extension instance using this event.
    * @param {string} params.name
    *        A name used only for debugging.
    * @param {functon} params.register
    *        A function called whenever a new listener is added.
    * @param {boolean} [params.inputHandling=false]
    *        If true, the "handling user input" flag is set while handlers
    *        for this event are executing.
+   * @param {object} [params.persistent]
+   *        Details for persistent event listeners
+   * @param {string} params.persistent.module
+   *        The name of the module in which this event is defined.
+   * @param {string} params.persistent.event
+   *        The name of this event.
    */
   constructor(params) {
     // Maintain compatibility with the old EventManager API in which
     // the constructor took parameters (contest, name, register).
     // Remove this in bug 1451212.
     if (arguments.length > 1) {
       [this.context, this.name, this.register] = arguments;
       this.inputHandling = false;
+      this.persistent = null;
     } else {
-      let {context, name, register, inputHandling = false} = params;
+      let {context, name, register, inputHandling = false, persistent = null} = params;
       this.context = context;
       this.name = name;
       this.register = register;
       this.inputHandling = inputHandling;
+      this.persistent = persistent;
     }
+
     this.unregister = new Map();
+    this.remove = new Map();
+
+    if (this.persistent) {
+      if (this.context.viewType !== "background") {
+        this.persistent = null;
+      }
+      if (AppConstants.DEBUG) {
+        if (this.context.envType !== "addon_parent") {
+          throw new Error("Persistent event managers can only be created for addon_parent");
+        }
+        if (!this.persistent.module || !this.persistent.event) {
+          throw new Error("Persistent event manager must specify module and event");
+        }
+      }
+    }
+  }
+
+  /*
+   * Information about listeners to persistent events is associated with
+   * the extension to which they belong.  Any extension thas has such
+   * listeners has a property called `persistentListeners` that is a
+   * 3-level Map.  The first 2 keys are the module name (e.g., webRequest)
+   * and the name of the event within the module (e.g., onBeforeRequest).
+   * The third level of the map is used to track multiple listeners for
+   * the same event, these listeners are distinguished by the extra arguments
+   * passed to addListener().  For quick lookups, the key to the third Map
+   * is the result of calling uneval() on the array of extra arguments.
+   *
+   * The value stored in the Map is a plain object with a property called
+   * `params` that is the original (ie, not uneval()ed) extra arguments to
+   * addListener().  For a primed listener (i.e., the stub listener created
+   * during browser startup before the extension background page is started,
+   * the object also has a `primed` property that holds the things needed
+   * to handle events during startup and eventually connect the listener
+   * with a callback registered from the extension.
+   */
+  static _initPersistentListeners(extension) {
+    if (extension.persistentListeners) {
+      return;
+    }
+
+    let listeners = new DefaultMap(() => new DefaultMap(() => new Map()));
+    extension.persistentListeners = listeners;
+
+    let {persistentListeners} = extension.startupData;
+    if (!persistentListeners) {
+      return;
+    }
+
+    for (let [module, entry] of Object.entries(persistentListeners)) {
+      for (let [event, paramlists] of Object.entries(entry)) {
+        for (let paramlist of paramlists) {
+          let key = uneval(paramlist);
+          listeners.get(module).get(event).set(key, {params: paramlist});
+        }
+      }
+    }
+  }
+
+  // Extract just the information needed at startup for all persistent
+  // listeners, and arrange for it to be saved.  This should be called
+  // whenever the set of persistent listeners for an extension changes.
+  static _writePersistentListeners(extension) {
+    let startupListeners = {};
+    for (let [module, moduleEntry] of extension.persistentListeners) {
+      startupListeners[module] = {};
+      for (let [event, eventEntry] of moduleEntry) {
+        startupListeners[module][event] = Array.from(eventEntry.values(),
+                                                     listener => listener.params);
+      }
+    }
+
+    extension.startupData.persistentListeners = startupListeners;
+    extension.saveStartupData();
+  }
+
+  // Set up "primed" event listeners for any saved event listeners
+  // in an extension's startup data.
+  // This function is only called during browser startup, it stores details
+  // about all primed listeners in the extension's persistentListeners Map.
+  static primeListeners(extension) {
+    EventManager._initPersistentListeners(extension);
+
+    for (let [module, moduleEntry] of extension.persistentListeners) {
+      let api = extension.apiManager.getAPI(module, extension, "addon_parent");
+      for (let [event, eventEntry] of moduleEntry) {
+        for (let listener of eventEntry.values()) {
+          let primed = {pendingEvents: []};
+          listener.primed = primed;
+
+          let wakeup = (...args) => new Promise((resolve, reject) => {
+            primed.pendingEvents.push({args, resolve, reject});
+            extension.emit("background-page-event");
+          });
+
+          let fire = {
+            sync: wakeup,
+            async: wakeup,
+          };
+
+          let {unregister, convert} = api.primeListener(extension, event, fire, listener.params);
+          Object.assign(primed, {unregister, convert});
+        }
+      }
+    }
+  }
+
+  // Remove any primed listeners that were not re-registered.
+  // This function is called after the background page has started.
+  static clearPrimedListeners(extension) {
+    for (let [module, moduleEntry] of extension.persistentListeners) {
+      for (let [event, listeners] of moduleEntry) {
+        for (let [key, listener] of listeners) {
+          let {primed} = listener;
+          if (!primed) {
+            continue;
+          }
+
+          for (let evt of primed.pendingEvents) {
+            evt.reject(new Error("listener not re-registered"));
+          }
+
+          EventManager.clearPersistentListener(extension, module, event, key);
+          primed.unregister();
+        }
+      }
+    }
+  }
+
+  // Record the fact that there is a listener for the given event in
+  // the given extension.  `args` is an Array containing any extra
+  // arguments that were passed to addListener().
+  static savePersistentListener(extension, module, event, args) {
+    EventManager._initPersistentListeners(extension);
+    let key = uneval(args);
+    extension.persistentListeners.get(module).get(event).set(key, {params: args});
+    EventManager._writePersistentListeners(extension);
+  }
+
+  // Remove the record for the given event listener from the extension's
+  // startup data.  `key` must be a string, the result of calling uneval()
+  // on the array of extra arguments originally passed to addListener().
+  static clearPersistentListener(extension, module, event, key) {
+    let listeners = extension.persistentListeners.get(module).get(event);
+    listeners.delete(key);
+
+    if (listeners.size == 0) {
+      let moduleEntry = extension.persistentListeners.get(module);
+      moduleEntry.delete(event);
+      if (moduleEntry.size == 0) {
+        extension.persistentListeners.delete(module);
+      }
+    }
+
+    EventManager._writePersistentListeners(extension);
   }
 
   addListener(callback, ...args) {
     if (this.unregister.has(callback)) {
       return;
     }
 
     let shouldFire = () => {
@@ -1830,45 +1995,99 @@ class EventManager {
         return Promise.resolve().then(() => {
           if (shouldFire()) {
             return this.context.applySafeWithoutClone(callback, args);
           }
         });
       },
     };
 
-    let unregister = this.register(fire, ...args);
+    let {extension} = this.context;
+
+    let unregister = null;
+    let recordStartupData = false;
+
+    // If this is a persistent event, check for a listener that was already
+    // created during startup.  If there is one, use it and don't create a
+    // new one.
+    if (this.persistent) {
+      recordStartupData = true;
+      let {module, event} = this.persistent;
+
+      let key = uneval(args);
+      EventManager._initPersistentListeners(extension);
+      let listener = extension.persistentListeners
+                              .get(module).get(event).get(key);
+      if (listener) {
+        let {primed} = listener;
+        listener.primed = null;
+
+        primed.convert(fire);
+        unregister = primed.unregister;
+
+        for (let evt of primed.pendingEvents) {
+          evt.resolve(fire.async(...evt.args));
+        }
+
+        recordStartupData = false;
+        this.remove.set(callback, () => {
+          EventManager.clearPersistentListener(extension, module, event, uneval(args));
+        });
+      }
+    }
+
+    if (!unregister) {
+      unregister = this.register(fire, ...args);
+    }
+
     this.unregister.set(callback, unregister);
     this.context.callOnClose(this);
+
+    // If this is a new listener for a persistent event, record
+    // the details for subsequent startups.
+    if (recordStartupData) {
+      let {module, event} = this.persistent;
+      EventManager.savePersistentListener(extension, module, event, args);
+      this.remove.set(callback, () => {
+        EventManager.clearPersistentListener(extension, module, event, uneval(args));
+      });
+    }
   }
 
-  removeListener(callback) {
+  removeListener(callback, clearPersistentListener = true) {
     if (!this.unregister.has(callback)) {
       return;
     }
 
     let unregister = this.unregister.get(callback);
     this.unregister.delete(callback);
     try {
       unregister();
     } catch (e) {
       Cu.reportError(e);
     }
+
+    if (clearPersistentListener && this.remove.has(callback)) {
+      let cleanup = this.remove.get(callback);
+      this.remove.delete(callback);
+      cleanup();
+    }
+
     if (this.unregister.size == 0) {
       this.context.forgetOnClose(this);
     }
   }
 
   hasListener(callback) {
     return this.unregister.has(callback);
   }
 
   revoke() {
     for (let callback of this.unregister.keys()) {
-      this.removeListener(callback);
+      this.removeListener(callback, false);
     }
   }
 
   close() {
     this.revoke();
   }
 
   api() {
--- a/toolkit/components/extensions/parent/.eslintrc.js
+++ b/toolkit/components/extensions/parent/.eslintrc.js
@@ -9,16 +9,18 @@ module.exports = {
     "InputEventManager": true,
     "PRIVATE_STORE": true,
     "TabBase": true,
     "TabManagerBase": true,
     "TabTrackerBase": true,
     "WindowBase": true,
     "WindowManagerBase": true,
     "WindowTrackerBase": true,
+    "browserPaintedPromise": true,
+    "browserStartupPromise": true,
     "getContainerForCookieStoreId": true,
     "getCookieStoreIdForContainer": true,
     "getCookieStoreIdForTab": true,
     "isContainerCookieStoreId": true,
     "isDefaultCookieStoreId": true,
     "isPrivateCookieStoreId": true,
     "isValidCookieStoreId": true,
   },
--- a/toolkit/components/extensions/parent/ext-backgroundPage.js
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -4,16 +4,19 @@ ChromeUtils.defineModuleGetter(this, "Te
                                "resource://gre/modules/TelemetryStopwatch.jsm");
 
 ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
 var {
   HiddenExtensionPage,
   promiseExtensionViewLoaded,
 } = ExtensionParent;
 
+XPCOMUtils.defineLazyPreferenceGetter(this, "DELAYED_STARTUP",
+                                      "extensions.webextensions.background-delayed-startup");
+
 // 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;
 
@@ -49,19 +52,45 @@ class BackgroundPage extends HiddenExten
   shutdown() {
     this.extension._backgroundPageFrameLoader = null;
     super.shutdown();
   }
 }
 
 this.backgroundPage = class extends ExtensionAPI {
   onManifestEntry(entryName) {
-    let {manifest} = this.extension;
+    let {extension} = this;
+    let {manifest} = extension;
+
+    this.bgPage = new BackgroundPage(extension, manifest.background);
+    if (extension.startupReason !== "APP_STARTUP" || !DELAYED_STARTUP) {
+      return this.bgPage.build();
+    }
+
+    EventManager.primeListeners(extension);
+
+    extension.once("start-background-page", async () => {
+      await this.bgPage.build();
+      EventManager.clearPrimedListeners(extension);
+    });
 
-    this.bgPage = new BackgroundPage(this.extension, manifest.background);
+    // There are two ways to start the background page:
+    // 1. If a primed event fires, then start the background page as
+    //    soon as we have painted a browser window.  Note that we have
+    //    to touch browserPaintedPromise here to initialize the listener
+    //    or else we can miss it if the event occurs after the first
+    //    window is painted but before #2
+    // 2. After all windows have been restored.
+    void browserPaintedPromise;
+    extension.once("background-page-event", async () => {
+      await browserPaintedPromise;
+      extension.emit("start-background-page");
+    });
 
-    return this.bgPage.build();
+    browserStartupPromise.then(() => {
+      extension.emit("start-background-page");
+    });
   }
 
   onShutdown() {
     this.bgPage.shutdown();
   }
 };
--- a/toolkit/components/extensions/parent/ext-toolkit.js
+++ b/toolkit/components/extensions/parent/ext-toolkit.js
@@ -69,8 +69,29 @@ global.getContainerForCookieStoreId = fu
   return null;
 };
 
 global.isValidCookieStoreId = function(storeId) {
   return isDefaultCookieStoreId(storeId) ||
          isPrivateCookieStoreId(storeId) ||
          isContainerCookieStoreId(storeId);
 };
+
+function makeEventPromise(name, event) {
+  Object.defineProperty(global, name, {
+    get() {
+      let promise = ExtensionUtils.promiseObserved(event);
+      Object.defineProperty(global, name, {value: promise});
+      return promise;
+    },
+    configurable: true,
+    enumerable: true,
+  });
+}
+
+// browserPaintedPromise and browserStartupPromise are promises that
+// resolve after the first browser window is painted and after browser
+// windows have been restored, respectively.
+// These promises must be referenced during startup to be valid -- if the
+// first reference happens after the corresponding event has occurred,
+// the Promise will never resolve.
+makeEventPromise("browserPaintedPromise", "browser-delayed-startup-finished");
+makeEventPromise("browserStartupPromise", "sessionstore-windows-restored");
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
@@ -0,0 +1,355 @@
+"use strict";
+
+PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
+
+Cu.importGlobalProperties(["Blob", "URL"]);
+
+ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
+const {ExtensionAPI} = ExtensionCommon;
+
+const SCHEMA = [
+  {
+    namespace: "eventtest",
+    events: [
+      {
+        name: "onEvent1",
+        type: "function",
+        extraParameters: [{type: "any"}],
+      },
+      {
+        name: "onEvent2",
+        type: "function",
+        extraParameters: [{type: "any"}],
+      },
+    ],
+  },
+];
+
+// The code in this class does not actually run in this test scope, it is
+// serialized into a string which is later loaded by the WebExtensions
+// framework in the same context as other extension APIs.  By writing it
+// this way rather than as a big string constant we get lint coverage.
+// But eslint doesn't understand that this code runs in a different context
+// where the EventManager class is available so just tell it here:
+/* global EventManager */
+const API = class extends ExtensionAPI {
+  primeListener(extension, event, fire, params) {
+    let data = {wrappedJSObject: {event, params}};
+    Services.obs.notifyObservers(data, "prime-event-listener");
+
+    const FIRE_TOPIC = `fire-${event}`;
+
+    async function listener(subject, topic, _data) {
+      try {
+        await fire.async(subject.wrappedJSObject);
+      } catch (err) {
+        Services.obs.notifyObservers(data, "listener-callback-exception");
+      }
+    }
+    Services.obs.addObserver(listener, FIRE_TOPIC);
+
+    return {
+      unregister() {
+        Services.obs.notifyObservers(data, "unregister-primed-listener");
+        Services.obs.removeObserver(listener, FIRE_TOPIC);
+      },
+      convert(_fire) {
+        Services.obs.notifyObservers(data, "convert-event-listener");
+        fire = _fire;
+      },
+    };
+  }
+
+  getAPI(context) {
+    return {
+      eventtest: {
+        onEvent1: new EventManager({
+          context,
+          name: "test.event1",
+          persistent: {
+            module: "eventtest",
+            event: "onEvent1",
+          },
+          register: (fire, ...params) => {
+            let data = {wrappedJSObject: {event: "onEvent1", params}};
+            Services.obs.notifyObservers(data, "register-event-listener");
+            return () => {
+              Services.obs.notifyObservers(data, "unregister-event-listener");
+            };
+          },
+        }).api(),
+
+        onEvent2: new EventManager({
+          context,
+          name: "test.event1",
+          persistent: {
+            module: "eventtest",
+            event: "onEvent2",
+          },
+          register: (fire, ...params) => {
+            let data = {wrappedJSObject: {event: "onEvent2", params}};
+            Services.obs.notifyObservers(data, "register-event-listener");
+            return () => {
+              Services.obs.notifyObservers(data, "unregister-event-listener");
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
+
+const API_SCRIPT = `this.eventtest = ${API.toString()}`;
+
+const MODULE_INFO = {
+  eventtest: {
+    schema: `data:,${JSON.stringify(SCHEMA)}`,
+    scopes: ["addon_parent"],
+    paths: [["eventtest"]],
+    url: URL.createObjectURL(new Blob([API_SCRIPT])),
+  },
+};
+
+const global = this;
+
+// Wait for the given event (topic) to occur a specific number of times
+// (count).  If fn is not supplied, the Promise returned from this function
+// resolves as soon as that many instances of the event have been observed.
+// If fn is supplied, this function also waits for the Promise that fn()
+// returns to complete and ensures that the given event does not occur more
+// than `count` times before then.  On success, resolves with an array
+// of the subjects from each of the observed events.
+async function promiseObservable(topic, count, fn = null) {
+  let _countResolve;
+  let results = [];
+  function listener(subject, _topic, data) {
+    results.push(subject.wrappedJSObject);
+    if (results.length > count) {
+      ok(false, `Got unexpected ${topic} event`);
+    } else if (results.length == count) {
+      _countResolve();
+    }
+  }
+  Services.obs.addObserver(listener, topic);
+
+  try {
+    await Promise.all([
+      new Promise(resolve => { _countResolve = resolve; }),
+      fn && fn(),
+    ]);
+  } finally {
+    Services.obs.removeObserver(listener, topic);
+  }
+
+  return results;
+}
+
+add_task(async function() {
+  Services.prefs.setBoolPref("extensions.webextensions.background-delayed-startup", true);
+
+  AddonTestUtils.init(global);
+  AddonTestUtils.overrideCertDB();
+  AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "43");
+
+  await AddonTestUtils.promiseStartupManager();
+
+  ExtensionParent.apiManager.registerModules(MODULE_INFO);
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    background() {
+      let register1 = true, register2 = true;
+      if (localStorage.getItem("skip1")) {
+        register1 = false;
+      }
+      if (localStorage.getItem("skip2")) {
+        register2 = false;
+      }
+
+      let listener1 = arg => browser.test.sendMessage("listener1", arg);
+      let listener2 = arg => browser.test.sendMessage("listener2", arg);
+      let listener3 = arg => browser.test.sendMessage("listener3", arg);
+
+      if (register1) {
+        browser.eventtest.onEvent1.addListener(listener1, "listener1");
+      }
+      if (register2) {
+        browser.eventtest.onEvent1.addListener(listener2, "listener2");
+        browser.eventtest.onEvent2.addListener(listener3, "listener3");
+      }
+
+      browser.test.onMessage.addListener(msg => {
+        if (msg == "unregister2") {
+          browser.eventtest.onEvent2.removeListener(listener3);
+          localStorage.setItem("skip2", true);
+        } else if (msg == "unregister1") {
+          localStorage.setItem("skip1", true);
+          browser.test.sendMessage("unregistered");
+        }
+      });
+
+      browser.test.sendMessage("ready");
+    },
+  });
+
+  function check(info, what, {listener1 = true, listener2 = true, listener3 = true} = {}) {
+    let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0);
+    equal(info.length, count, `Got ${count} ${what} events`);
+
+    let i = 0;
+    if (listener1) {
+      equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`);
+      deepEqual(info[i].params, ["listener1"], `Got event1 ${what} args for listener 1`);
+      ++i;
+    }
+
+    if (listener2) {
+      equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`);
+      deepEqual(info[i].params, ["listener2"], `Got event1 ${what} args for listener 2`);
+      ++i;
+    }
+
+    if (listener3) {
+      equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`);
+      deepEqual(info[i].params, ["listener3"], `Got event2 ${what} args for listener 3`);
+      ++i;
+    }
+  }
+
+  // Check that the regular event registration process occurs when
+  // the extension is installed.
+  let [info] = await Promise.all([
+    promiseObservable("register-event-listener", 3),
+    extension.startup(),
+  ]);
+  check(info, "register");
+
+  await extension.awaitMessage("ready");
+
+  // Check that the regular unregister process occurs when
+  // the browser shuts down.
+  [info] = await Promise.all([
+    promiseObservable("unregister-event-listener", 3),
+    new Promise(resolve => extension.extension.once("shutdown", resolve)),
+    AddonTestUtils.promiseShutdownManager(),
+  ]);
+  check(info, "unregister");
+
+  // Check that listeners are primed at the next browser startup.
+  [info] = await Promise.all([
+    promiseObservable("prime-event-listener", 3),
+    AddonTestUtils.promiseStartupManager(false),
+  ]);
+  check(info, "prime");
+
+  // Check that primed listeners are converted to regular listeners
+  // when the background page is started after browser startup.
+  let p = promiseObservable("convert-event-listener", 3);
+  Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+  info = await p;
+
+  check(info, "convert");
+
+  await extension.awaitMessage("ready");
+
+  // Check that when the event is triggered, all the plumbing worked
+  // correctly for the primed-then-converted listener.
+  let eventDetails = {test: "kaboom"};
+  let eventSubject = {wrappedJSObject: eventDetails};
+  Services.obs.notifyObservers(eventSubject, "fire-onEvent1");
+
+  let details = await extension.awaitMessage("listener1");
+  deepEqual(details, eventDetails, "Listener 1 fired");
+  details = await extension.awaitMessage("listener2");
+  deepEqual(details, eventDetails, "Listener 2 fired");
+
+  // Check that the converted listener is properly unregistered at
+  // browser shutdown.
+  [info] = await Promise.all([
+    promiseObservable("unregister-primed-listener", 3),
+    AddonTestUtils.promiseShutdownManager(),
+  ]);
+  check(info, "unregister");
+
+  // Start up again, listener should be primed
+  [info] = await Promise.all([
+    promiseObservable("prime-event-listener", 3),
+    AddonTestUtils.promiseStartupManager(false),
+  ]);
+  check(info, "prime");
+
+  // Check that triggering the event before the listener has been converted
+  // causes the background page to be loaded and the listener to be converted,
+  // and the listener is invoked.
+  p = promiseObservable("convert-event-listener", 3);
+  eventDetails.test = "startup event";
+  Services.obs.notifyObservers(eventSubject, "fire-onEvent2");
+  info = await p;
+
+  check(info, "convert");
+
+  details = await extension.awaitMessage("listener3");
+  deepEqual(details, eventDetails, "Listener 3 fired for event during startup");
+
+  await extension.awaitMessage("ready");
+
+  // Check that the unregister process works when we manually remove
+  // a listener.
+  p = promiseObservable("unregister-primed-listener", 1);
+  extension.sendMessage("unregister2");
+  info = await p;
+  check(info, "unregister", {listener1: false, listener2: false});
+
+  // Check that we only get unregisters for the remaining events after
+  // one listener has been removed.
+  info = await promiseObservable("unregister-primed-listener", 2,
+                                 () => AddonTestUtils.promiseShutdownManager());
+  check(info, "unregister", {listener3: false});
+
+  // Check that after restart, only listeners that were present at
+  // the end of the last session are primed.
+  info = await promiseObservable("prime-event-listener", 2,
+                                 () => AddonTestUtils.promiseStartupManager(false));
+  check(info, "prime", {listener3: false});
+
+  // Check that if the background script does not re-register listeners,
+  // the primed listeners are unregistered after the background page
+  // starts up.
+  p = promiseObservable("unregister-primed-listener", 1,
+                        () => extension.awaitMessage("ready"));
+  Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+  info = await p;
+  check(info, "unregister", {listener1: false, listener3: false});
+
+  // Just listener1 should be registered now, fire event1 to confirm.
+  eventDetails.test = "third time";
+  Services.obs.notifyObservers(eventSubject, "fire-onEvent1");
+  details = await extension.awaitMessage("listener1");
+  deepEqual(details, eventDetails, "Listener 1 fired");
+
+  // Tell the extension not to re-register listener1 on the next startup
+  extension.sendMessage("unregister1");
+  await extension.awaitMessage("unregistered");
+
+  // Shut down, start up
+  info = await promiseObservable("unregister-primed-listener", 1,
+                                 () => AddonTestUtils.promiseShutdownManager());
+  check(info, "unregister", {listener2: false, listener3: false});
+
+  info = await promiseObservable("prime-event-listener", 1,
+                                 () => AddonTestUtils.promiseStartupManager(false));
+  check(info, "register", {listener2: false, listener3: false});
+
+  // Check that firing event1 causes the listener fire callback to
+  // reject.
+  p = promiseObservable("listener-callback-exception", 1);
+  Services.obs.notifyObservers(eventSubject, "fire-onEvent1");
+  await p;
+  ok(true, "Primed listener that was not re-registered received an error when event was triggered during startup");
+
+  await extension.awaitMessage("ready");
+
+  await extension.unload();
+
+  await AddonTestUtils.promiseShutdownManager();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -56,16 +56,17 @@ skip-if = os == "android" # checking for
 [test_ext_legacy_extension_embedding.js]
 [test_ext_localStorage.js]
 [test_ext_management.js]
 skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows
 [test_ext_management_uninstall_self.js]
 [test_ext_onmessage_removelistener.js]
 skip-if = true # This test no longer tests what it is meant to test.
 [test_ext_permission_xhr.js]
+[test_ext_persistent_events.js]
 [test_ext_privacy.js]
 [test_ext_privacy_disable.js]
 [test_ext_privacy_update.js]
 [test_ext_proxy_auth.js]
 [test_ext_proxy_onauthrequired.js]
 [test_ext_proxy_socks.js]
 [test_ext_proxy_speculative.js]
 [test_ext_redirects.js]