Bug 1369841 Make browser.runtime.onMessage and onConnect persistent draft
authorAndrew Swan <aswan@mozilla.com>
Tue, 29 May 2018 17:40:53 -0700
changeset 804475 486903343200febc31b10255b802b1a22c07dba0
parent 803594 61d4a0442cb3cd5da7131bb1106701c9732ef8b3
push id112376
push useraswan@mozilla.com
push dateTue, 05 Jun 2018 22:33:47 +0000
bugs1369841
milestone62.0a1
Bug 1369841 Make browser.runtime.onMessage and onConnect persistent MozReview-Commit-ID: 4LdBeEERtsD
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/ExtensionParent.jsm
toolkit/components/extensions/parent/ext-backgroundPage.js
toolkit/components/extensions/parent/ext-runtime.js
toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
toolkit/mozapps/extensions/AddonManager.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1445,17 +1445,17 @@ class Extension extends ExtensionData {
     return (AddonSettings.ALLOW_LEGACY_EXTENSIONS ||
             this.isPrivileged);
   }
 
   saveStartupData() {
     if (this.dontSaveStartupData) {
       return;
     }
-    AddonManagerPrivate.setStartupData(this.id, this.startupData);
+    XPIProvider.setStartupData(this.id, this.startupData);
   }
 
   async _parseManifest() {
     let manifest = await super.parseManifest();
     if (manifest && manifest.permissions.has("mozillaAddons") &&
         !this.isPrivileged) {
       Cu.reportError(`Stripping mozillaAddons permission from ${this.id}`);
       manifest.permissions.delete("mozillaAddons");
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -462,19 +462,28 @@ class Messenger {
               return StrongPromise.wrap(result, channelId, caller);
             } else if (result === true) {
               return StrongPromise.wrap(promise, channelId, caller);
             }
             return response;
           },
         };
 
+        const childManager = this.context.viewType == "background" ? this.context.childManager : null;
         MessageChannel.addListener(this.messageManagers, "Extension:Message", listener);
+        if (childManager) {
+          childManager.callParentFunctionNoReturn("runtime.addMessagingListener",
+                                                  ["onMessage"]);
+        }
         return () => {
           MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener);
+          if (childManager) {
+            childManager.callParentFunctionNoReturn("runtime.removeMessagingListener",
+                                                    ["onMessage"]);
+          }
         };
       },
     }).api();
   }
 
   onMessage(name) {
     return this._onMessage(name, sender => sender.id === this.sender.id);
   }
@@ -547,19 +556,28 @@ class Messenger {
               delete recipient.tab;
             }
             let port = new Port(this.context, mm, this.messageManagers, name, portId, sender, recipient);
             fire.asyncWithoutClone(port.api());
             return true;
           },
         };
 
+        const childManager = this.context.viewType == "background" ? this.context.childManager : null;
         MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener);
+        if (childManager) {
+          childManager.callParentFunctionNoReturn("runtime.addMessagingListener",
+                                                  ["onConnect"]);
+        }
         return () => {
           MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener);
+          if (childManager) {
+            childManager.callParentFunctionNoReturn("runtime.removeMessagingListener",
+                                                    ["onConnect"]);
+          }
         };
       },
     }).api();
   }
 
   onConnect(name) {
     return this._onConnect(name, sender => sender.id === this.sender.id);
   }
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -1822,16 +1822,29 @@ class EventManager {
 
           let {unregister, convert} = api.primeListener(extension, event, fire, listener.params);
           Object.assign(primed, {unregister, convert});
         }
       }
     }
   }
 
+  // Remove a primed listener for the given event (with the given extra
+  // addListener arguments).  This ordinarily happens as a side effect of
+  // calling addListener(), but APIs that need special handling (e.g.,
+  // runtime.onConnect and onMessage which don't have EventManagers in the
+  // parent process) can use this directly.
+  static clearOnePrimedListener(extension, module, event, args = []) {
+    let key = uneval(args);
+    let listener = extension.persistentListeners.get(module).get(event).get(key);
+    if (listener.primed) {
+      listener.primed = null;
+    }
+  }
+
   // 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) {
@@ -1847,27 +1860,27 @@ class EventManager {
         }
       }
     }
   }
 
   // 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) {
+  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) {
+  static clearPersistentListener(extension, module, event, key = uneval([])) {
     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);
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -340,16 +340,21 @@ ProxyMessenger = {
     }
 
     const noHandlerError = {
       result: MessageChannel.RESULT_NO_HANDLER,
       message: "No matching message handler for the given recipient.",
     };
 
     let extension = GlobalManager.extensionMap.get(sender.extensionId);
+
+    if (extension.wakeupBackground) {
+      await extension.wakeupBackground();
+    }
+
     let {
       messageManager: receiverMM,
       xulBrowser: receiverBrowser,
     } = this.getMessageManagerForRecipient(recipient);
     if (!extension || !receiverMM) {
       return Promise.reject(noHandlerError);
     }
 
@@ -1724,41 +1729,40 @@ class CacheStore {
     StartupCache.save();
   }
 }
 
 for (let name of StartupCache.STORE_NAMES) {
   StartupCache[name] = new CacheStore(name);
 }
 
-function makeStartupPromise(event) {
-  return ExtensionUtils.promiseObserved(event).then(() => {});
-}
-
-// browserPaintedPromise and browserStartupPromise are promises that
-// resolve after the first browser window is painted and after browser
-// windows have been restored, respectively.
-let browserPaintedPromise = makeStartupPromise("browser-delayed-startup-finished");
-let browserStartupPromise = makeStartupPromise("sessionstore-windows-restored");
-
 var ExtensionParent = {
   GlobalManager,
   HiddenExtensionPage,
   IconDetails,
   ParentAPIManager,
   StartupCache,
   WebExtensionPolicy,
   apiManager,
-  browserPaintedPromise,
-  browserStartupPromise,
   promiseExtensionViewLoaded,
   watchExtensionProxyContextLoad,
   DebugUtils,
 };
 
+// browserPaintedPromise and browserStartupPromise are promises that
+// resolve after the first browser window is painted and after browser
+// windows have been restored, respectively.
+// _resetStartupPromises should only be called from outside this file in tests.
+ExtensionParent._resetStartupPromises = () => {
+  ExtensionParent.browserPaintedPromise = promiseObserved("browser-delayed-startup-finished").then(() => {});
+  ExtensionParent.browserStartupPromise = promiseObserved("sessionstore-windows-restored").then(() => {});
+};
+ExtensionParent._resetStartupPromises();
+
+
 XPCOMUtils.defineLazyGetter(ExtensionParent, "PlatformInfo", () => {
   return Object.freeze({
     os: (function() {
       let os = AppConstants.platform;
       if (os == "macosx") {
         os = "mac";
       }
       return os;
--- a/toolkit/components/extensions/parent/ext-backgroundPage.js
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -1,18 +1,16 @@
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "TelemetryStopwatch",
                                "resource://gre/modules/TelemetryStopwatch.jsm");
 
 ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
 var {
   HiddenExtensionPage,
-  browserPaintedPromise,
-  browserStartupPromise,
   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 {
@@ -77,21 +75,21 @@ this.backgroundPage = class extends Exte
     // 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.
     extension.once("background-page-event", async () => {
-      await browserPaintedPromise;
+      await ExtensionParent.browserPaintedPromise;
       extension.emit("start-background-page");
     });
 
-    browserStartupPromise.then(() => {
+    ExtensionParent.browserStartupPromise.then(() => {
       extension.emit("start-background-page");
     });
   }
 
   onShutdown() {
     this.bgPage.shutdown();
   }
 };
--- a/toolkit/components/extensions/parent/ext-runtime.js
+++ b/toolkit/components/extensions/parent/ext-runtime.js
@@ -1,22 +1,30 @@
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
 ChromeUtils.defineModuleGetter(this, "AddonManagerPrivate",
                                "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionCommon",
+                               "resource://gre/modules/ExtensionCommon.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionParent",
                                "resource://gre/modules/ExtensionParent.jsm");
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "DevToolsShim",
                                "chrome://devtools-startup/content/DevToolsShim.jsm");
 
 this.runtime = class extends ExtensionAPI {
+  constructor(...args) {
+    super(...args);
+
+    this.messagingListeners = new Map();
+  }
+
   getAPI(context) {
     let {extension} = context;
     return {
       runtime: {
         onStartup: new EventManager({
           context,
           name: "runtime.onStartup",
           register: fire => {
@@ -150,12 +158,58 @@ this.runtime = class extends ExtensionAP
         // This function is not exposed to the extension js code and it is only
         // used by the alert function redefined into the background pages to be
         // able to open the BrowserConsole from the main process.
         openBrowserConsole() {
           if (AppConstants.platform !== "android") {
             DevToolsShim.openBrowserConsole();
           }
         },
+
+        // Used internally by onMessage/onConnect
+        addMessagingListener: event => {
+          let count = (this.messagingListeners.get(event) || 0) + 1;
+          this.messagingListeners.set(event, count);
+          if (count == 1) {
+            ExtensionCommon.EventManager.savePersistentListener(extension,
+                                                                "runtime", event);
+          }
+
+          ExtensionCommon.EventManager.clearOnePrimedListener(extension,
+                                                              "runtime", event);
+        },
+
+        removeMessagingListener: event => {
+          let count = this.messagingListeners.get(event);
+          if (!count) {
+            return;
+          }
+          this.messagingListeners.set(event, --count);
+          if (count == 0) {
+            ExtensionCommon.EventManager.clearPersistentListener(extension,
+                                                                 "runtime", event);
+          }
+        },
+      },
+    };
+  }
+
+  primeListener(extension, event, fire, params) {
+    // The real work happens in ProxyMessenger which, if
+    // extension.wakeupBackground is set, holds the underlying messages
+    // that implement extension messaging until its Promise resolves.
+    // We rely on the ordering of these messages being preserved so be
+    // careful here to always return the same Promise, otherwise promise
+    // scheduling can inadvertently re-order messages.
+    extension.wakeupBackground = () => {
+      let promise = fire.wakeup();
+      promise.then(() => { extension.wakeupBackground = undefined; });
+      extension.wakeupBackground = () => promise;
+      return promise;
+    };
+
+    return {
+      unregister() {
+        extension.wakeupBackground = undefined;
       },
     };
   }
 };
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
@@ -0,0 +1,176 @@
+"use strict";
+
+PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
+
+const server = createHttpServer({hosts: ["example.com"]});
+server.registerDirectory("/data/", do_get_file("data"));
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "43");
+
+let {
+  promiseRestartManager,
+  promiseShutdownManager,
+  promiseStartupManager,
+} = AddonTestUtils;
+
+Services.prefs.setBoolPref("extensions.webextensions.background-delayed-startup", true);
+
+function trackEvents(wrapper) {
+  let events = new Map();
+  for (let event of ["background-page-event", "start-background-page"]) {
+    events.set(event, false);
+    wrapper.extension.once(event, () => events.set(event, true));
+  }
+  return events;
+}
+
+async function test(what, background, script) {
+  await promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+
+    manifest: {
+      content_scripts: [
+        {
+          matches: ["http://example.com/*"],
+          js: ["script.js"],
+        },
+      ],
+    },
+
+    files: {
+      "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`,
+      "script.js": script,
+    },
+
+    background,
+  });
+
+  info(`Set up ${what} listener`);
+  await extension.startup();
+  await extension.awaitMessage("bg-ran");
+
+  info(`Test wakeup for ${what} from an extension page`);
+  await promiseRestartManager();
+  await extension.awaitStartup();
+
+  function awaitBgEvent() {
+    return new Promise(resolve => extension.extension.once("background-page-event", resolve));
+  }
+
+  let events = trackEvents(extension);
+
+  let url = extension.extension.baseURI.resolve("page.html");
+
+  let [, page] = await Promise.all([
+    awaitBgEvent(),
+    ExtensionTestUtils.loadContentPage(url, {extension}),
+  ]);
+
+  equal(events.get("background-page-event"), true,
+        "Should have gotten a background page event");
+  equal(events.get("start-background-page"), false,
+        "Background page should not be started");
+
+  equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+  let promise = extension.awaitMessage("bg-ran");
+  Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+  await promise;
+
+  equal(events.get("start-background-page"), true,
+        "Should have gotten start-background-page event");
+
+  await extension.awaitFinish("messaging-test");
+  ok(true, "Background page loaded and received message from extension page");
+
+  await page.close();
+
+  info(`Test wakeup for ${what} from a content script`);
+  ExtensionParent._resetStartupPromises();
+  await promiseRestartManager();
+  await extension.awaitStartup();
+
+  events = trackEvents(extension);
+
+  [, page] = await Promise.all([
+    awaitBgEvent(),
+    ExtensionTestUtils.loadContentPage("http://example.com/data/file_sample.html"),
+  ]);
+
+  equal(events.get("background-page-event"), true,
+        "Should have gotten a background page event");
+  equal(events.get("start-background-page"), false,
+        "Background page should not be started");
+
+  equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+  promise = extension.awaitMessage("bg-ran");
+  Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+  await promise;
+
+  equal(events.get("start-background-page"), true,
+        "Should have gotten start-background-page event");
+
+  await extension.awaitFinish("messaging-test");
+  ok(true, "Background page loaded and received message from content script");
+
+  await page.close();
+  await extension.unload();
+
+  ExtensionParent._resetStartupPromises();
+  await promiseShutdownManager();
+}
+
+add_task(function test_onMessage() {
+  function script() {
+    browser.runtime.sendMessage("ping").then(reply => {
+      browser.test.assertEq(reply, "pong", "Extension page received pong reply");
+      browser.test.notifyPass("messaging-test");
+    });
+  }
+
+  async function background() {
+    browser.runtime.onMessage.addListener((msg, sender) => {
+      browser.test.assertEq(msg, "ping", "Background page received ping message");
+      return Promise.resolve("pong");
+    });
+
+    // addListener() returns right away but make a round trip to the
+    // main process to ensure the persistent onMessage listener is recorded.
+    await browser.runtime.getBrowserInfo();
+    browser.test.sendMessage("bg-ran");
+  }
+
+  return test("onMessage", background, script);
+});
+
+add_task(function test_onConnect() {
+  function script() {
+    let port = browser.runtime.connect();
+    port.onMessage.addListener(msg => {
+      browser.test.assertEq(msg, "pong", "Extension page received pong reply");
+      browser.test.notifyPass("messaging-test");
+    });
+    port.postMessage("ping");
+  }
+
+  async function background() {
+    browser.runtime.onConnect.addListener(port => {
+      port.onMessage.addListener(msg => {
+        browser.test.assertEq(msg, "ping", "Background page received ping message");
+        port.postMessage("pong");
+      });
+    });
+
+    // addListener() returns right away but make a round trip to the
+    // main process to ensure the persistent onMessage listener is recorded.
+    await browser.runtime.getBrowserInfo();
+    browser.test.sendMessage("bg-ran");
+  }
+
+  return test("onConnect", background, script);
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -54,16 +54,17 @@ skip-if = os == "android" # checking for
 [test_ext_geturl.js]
 [test_ext_idle.js]
 [test_ext_legacy_extension_context.js]
 [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_messaging_startup.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]
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3020,36 +3020,16 @@ var AddonManagerPrivate = {
     return AddonManagerInternal._getProviderByName("XPIProvider")
                                .isTemporaryInstallID(extensionId);
   },
 
   isDBLoaded() {
     let provider = AddonManagerInternal._getProviderByName("XPIProvider");
     return provider ? provider.isDBLoaded : false;
   },
-
-  /**
-   * Sets startupData for the given addon.  The provided data will be stored
-   * in addonsStartup.json so it is available early during browser startup.
-   * Note that this file is read synchronously at startup, so startupData
-   * should be used with care.
-   *
-   * @param {string} aID
-   *         The id of the addon to save startup data for.
-   * @param {any} aData
-   *        The data to store.  Must be JSON serializable.
-   */
-  setStartupData(aID, aData) {
-    if (!gStarted)
-      throw Components.Exception("AddonManager is not initialized",
-                                 Cr.NS_ERROR_NOT_INITIALIZED);
-
-    AddonManagerInternal._getProviderByName("XPIProvider")
-                        .setStartupData(aID, aData);
-  },
 };
 
 /**
  * This is the public API that UI and developers should be calling. All methods
  * just forward to AddonManagerInternal.
  * @class
  */
 var AddonManager = {