Bug 1210583: Part 1 - [webext] Add support for cross-process messaging with async responses. r=billm
authorKris Maglione <maglione.k@gmail.com>
Wed, 27 Jan 2016 12:57:21 -0800
changeset 281859 65b634919d299de20908a8dbffe33ba8a4ab6ad9
parent 281858 77c38dd031d41fb5176dd6ce8d5c6e79c5d7510d
child 281860 261e997621c182a03bc330c8bd18c98eddb9c7eb
push id17269
push usermaglione.k@gmail.com
push dateThu, 28 Jan 2016 00:48:07 +0000
treeherderfx-team@261e997621c1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbillm
bugs1210583
milestone47.0a1
Bug 1210583: Part 1 - [webext] Add support for cross-process messaging with async responses. r=billm
browser/components/extensions/ext-tabs.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/MessageChannel.jsm
toolkit/components/extensions/moz.build
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -3,16 +3,18 @@
 "use strict";
 
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 
 var {
   EventManager,
@@ -443,24 +445,20 @@ extensions.registerSchemaAPI("tabs", nul
 
       _execute: function(tabId, details, kind, callback) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         let mm = tab.linkedBrowser.messageManager;
 
         let options = {
           js: [],
           css: [],
+        };
 
-          // We need to send the inner window ID to make sure we only
-          // execute the script if the window is currently navigated to
-          // the document that we expect.
-          //
-          // TODO: When we add support for callbacks, non-matching
-          // window IDs and insufficient permissions need to result in a
-          // callback with |lastError| set.
+        let recipient = {
+          extensionId: extension.id,
           innerWindowID: tab.linkedBrowser.innerWindowID,
         };
 
         if (TabManager.for(extension).hasActiveTabPermission(tab)) {
           // If we have the "activeTab" permission for this tab, ignore
           // the host whitelist.
           options.matchesHost = ["<all_urls>"];
         } else {
@@ -482,18 +480,18 @@ extensions.registerSchemaAPI("tabs", nul
           options.all_frames = details.allFrames;
         }
         if (details.matchAboutBlank) {
           options.match_about_blank = details.matchAboutBlank;
         }
         if (details.runAt !== null) {
           options.run_at = details.runAt;
         }
-        mm.sendAsyncMessage("Extension:Execute",
-                            {extensionId: extension.id, options});
+
+        MessageChannel.sendMessage(mm, "Extension:Execute", { options }, recipient);
 
         // TODO: Call the callback with the result (which is what???).
       },
 
       executeScript: function(tabId, details, callback) {
         self.tabs._execute(tabId, details, "js", callback);
       },
 
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -45,16 +45,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 
 Cu.import("resource://gre/modules/ExtensionManagement.jsm");
 
 // Register built-in parts of the API. Other parts may be registered
 // in browser/, mobile/, or b2g/.
 ExtensionManagement.registerScript("chrome://extensions/content/ext-alarms.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-backgroundPage.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-cookies.js");
@@ -1071,16 +1073,18 @@ Extension.prototype = extend(Object.crea
     for (let obj of this.onShutdown) {
       obj.close();
     }
 
     Management.emit("shutdown", this);
 
     Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id});
 
+    MessageChannel.abortResponses({ extensionId: this.id });
+
     ExtensionManagement.shutdownExtension(this.uuid);
 
     // Clean up a generated file.
     this.cleanupGeneratedFile();
   },
 
   observe(subject, topic, data) {
     if (topic == "xpcom-shutdown") {
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -24,16 +24,18 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/AppConstants.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                   "resource://gre/modules/ExtensionManagement.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   runSafeSyncWithoutClone,
   LocaleData,
   MessageBroker,
   Messenger,
   injectAPI,
@@ -132,26 +134,16 @@ Script.prototype = {
     if (this.exclude_matches_.matches(uri)) {
       return false;
     }
 
     if (!this.options.all_frames && window.top != window) {
       return false;
     }
 
-    if ("innerWindowID" in this.options) {
-      let innerWindowID = window.QueryInterface(Ci.nsIInterfaceRequestor)
-                                .getInterface(Ci.nsIDOMWindowUtils)
-                                .currentInnerWindowID;
-
-      if (innerWindowID !== this.options.innerWindowID) {
-        return false;
-      }
-    }
-
     // TODO: match_about_blank.
 
     return true;
   },
 
   tryInject(extension, window, sandbox, shouldRun) {
     if (!this.matches(window)) {
       return;
@@ -399,16 +391,18 @@ var DocumentManager = {
       this.trigger("document_start", window);
       /* eslint-disable mozilla/balanced-listeners */
       window.addEventListener("DOMContentLoaded", this, true);
       window.addEventListener("load", this, true);
       /* eslint-enable mozilla/balanced-listeners */
     } else if (topic == "inner-window-destroyed") {
       let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
 
+      MessageChannel.abortResponses({ innerWindowID: windowId });
+
       // Close any existent content-script context for the destroyed window.
       if (this.contentScriptWindows.has(windowId)) {
         let extensions = this.contentScriptWindows.get(windowId);
         for (let [, context] of extensions) {
           context.close();
         }
 
         this.contentScriptWindows.delete(windowId);
@@ -441,17 +435,17 @@ var DocumentManager = {
       this.trigger("document_idle", window);
     }
   },
 
   executeScript(global, extensionId, script) {
     let window = global.content;
     let context = this.getContentScriptContext(extensionId, window);
     if (!context) {
-      return;
+      throw new Error("Unexpected add-on ID");
     }
 
     // TODO: Somehow make sure we have the right permissions for this origin!
 
     // FIXME: Script should be executed only if current state has
     // already reached its run_at state, or we have to keep it around
     // somewhere to execute later.
     context.execute(script, scheduled => true);
@@ -530,16 +524,18 @@ var DocumentManager = {
     // Clean up iframe extension page contexts on extension shutdown.
     for (let [winId, context] of this.extensionPageWindows) {
       if (context.extensionId == extensionId) {
         context.close();
         this.extensionPageWindows.delete(winId);
       }
     }
 
+    MessageChannel.abortResponses({ extensionId });
+
     this.extensionCount--;
     if (this.extensionCount == 0) {
       this.uninit();
     }
   },
 
   trigger(when, window) {
     let state = this.getWindowState(window);
@@ -640,50 +636,66 @@ ExtensionManager = {
         flushJarCache(file);
         Services.cpmm.sendAsyncMessage("Extension:FlushJarCacheComplete");
         break;
       }
     }
   },
 };
 
+class ExtensionGlobal {
+  constructor(global) {
+    this.global = global;
+
+    MessageChannel.addListener(global, "Extension:Execute", this);
+
+    this.broker = new MessageBroker([global]);
+
+    this.windowId = global.content
+                          .QueryInterface(Ci.nsIInterfaceRequestor)
+                          .getInterface(Ci.nsIDOMWindowUtils)
+                          .outerWindowID;
+
+    global.sendAsyncMessage("Extension:TopWindowID", { windowId: this.windowId });
+  }
+
+  uninit() {
+    this.global.sendAsyncMessage("Extension:RemoveTopWindowID", { windowId: this.windowId });
+  }
+
+  get messageFilter() {
+    return {
+      innerWindowID: this.global.content
+                         .QueryInterface(Ci.nsIInterfaceRequestor)
+                         .getInterface(Ci.nsIDOMWindowUtils)
+                         .currentInnerWindowID,
+    };
+  }
+
+  receiveMessage({ target, messageName, recipient, data }) {
+    switch (messageName) {
+      case "Extension:Execute":
+        let script = new Script(data.options);
+        let { extensionId } = recipient;
+        DocumentManager.executeScript(target, extensionId, script);
+        break;
+    }
+  }
+}
+
 this.ExtensionContent = {
   globals: new Map(),
 
   init(global) {
-    let broker = new MessageBroker([global]);
-    this.globals.set(global, broker);
-
-    global.addMessageListener("Extension:Execute", this);
-
-    let windowId = global.content
-                         .QueryInterface(Ci.nsIInterfaceRequestor)
-                         .getInterface(Ci.nsIDOMWindowUtils)
-                         .outerWindowID;
-    global.sendAsyncMessage("Extension:TopWindowID", {windowId});
+    this.globals.set(global, new ExtensionGlobal(global));
   },
 
   uninit(global) {
+    this.globals.get(global).uninit();
     this.globals.delete(global);
-
-    let windowId = global.content
-                         .QueryInterface(Ci.nsIInterfaceRequestor)
-                         .getInterface(Ci.nsIDOMWindowUtils)
-                         .outerWindowID;
-    global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId});
   },
 
   getBroker(messageManager) {
-    return this.globals.get(messageManager);
-  },
-
-  receiveMessage({target, name, data}) {
-    switch (name) {
-      case "Extension:Execute":
-        let script = new Script(data.options);
-        let {extensionId} = data;
-        DocumentManager.executeScript(target, extensionId, script);
-        break;
-    }
+    return this.globals.get(messageManager).broker;
   },
 };
 
 ExtensionManager.init();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -0,0 +1,610 @@
+/* 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";
+
+/**
+ * This module provides wrappers around standard message managers to
+ * simplify bidirectional communication. It currently allows a caller to
+ * send a message to a single listener, and receive a reply. If there
+ * are no matching listeners, or the message manager disconnects before
+ * a reply is received, the caller is returned an error.
+ *
+ * Since each message must have only one recipient, the listener end may
+ * specify filters for the messages it wishes to receive, and the sender
+ * end likewise may specify recipient tags to match the filters.
+ *
+ * The message handler on the listener side may return its response
+ * value directly, or may return a promise, the resolution or rejection
+ * of which will be returned instead. The sender end likewise receives a
+ * promise which resolves or rejects to the listener's response.
+ *
+ *
+ * A basic setup works something like this:
+ *
+ * A content script adds a message listener to its global
+ * nsIContentFrameMessageManager, with an appropriate set of filters:
+ *
+ *  {
+ *    init(messageManager, window, extensionID) {
+ *      this.window = window;
+ *
+ *      MessageChannel.addListener(
+ *        messageManager, "ContentScript:TouchContent",
+ *        this);
+ *
+ *      this.messageFilter = {
+ *        innerWindowID: getInnerWindowID(window),
+ *        extensionID: extensionID,
+ *      };
+ *    },
+ *
+ *    receiveMessage({ target, messageName, sender, recipient, data }) {
+ *      if (messageName == "ContentScript:TouchContent") {
+ *        return new Promise(resolve => {
+ *          this.touchWindow(data.touchWith, result => {
+ *            resolve({ touchResult: result });
+ *          });
+ *        });
+ *      }
+ *    },
+ *  };
+ *
+ * A script in the parent process sends a message to the content process
+ * via a tab message manager, including recipient tags to match its
+ * filter, and an optional sender tag to identify itself:
+ *
+ *  let data = { touchWith: "pencil" };
+ *  let sender = { extensionID, contextID };
+ *  let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID };
+ *
+ *  MessageChannel.sendMessage(
+ *    tab.linkedBrowser.messageManager, "ContentScript:TouchContent",
+ *    data, recipient, sender
+ *  ).then(result => {
+ *    alert(result.touchResult);
+ *  });
+ *
+ * Since the lifetimes of message senders and receivers may not always
+ * match, either side of the message channel may cancel pending
+ * responses which match its sender or recipient tags.
+ *
+ * For the above client, this might be done from an
+ * inner-window-destroyed observer, when its target scope is destroyed:
+ *
+ *  observe(subject, topic, data) {
+ *    if (topic == "inner-window-destroyed") {
+ *      let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+ *
+ *      MessageChannel.abortResponses({ innerWindowID });
+ *    }
+ *  },
+ *
+ * From the parent, it may be done when its context is being destroyed:
+ *
+ *  onDestroy() {
+ *    MessageChannel.abortResponses({
+ *      extensionID: this.extensionID,
+ *      contextID: this.contextID,
+ *    });
+ *  },
+ *
+ */
+
+this.EXPORTED_SYMBOLS = ["MessageChannel"];
+
+/* globals MessageChannel */
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
+                                  "resource://gre/modules/PromiseUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+
+/**
+ * Handles the mapping and dispatching of messages to their registered
+ * handlers. There is one broker per message manager and class of
+ * messages. Each class of messages is mapped to one native message
+ * name, e.g., "MessageChannel:Message", and is dispatched to handlers
+ * based on an internal message name, e.g., "Extension:ExecuteScript".
+ */
+class FilteringMessageManager {
+  /**
+   * @param {string} messageName
+   *     The name of the native message this broker listens for.
+   * @param {function} callback
+   *     A function which is called for each message after it has been
+   *     mapped to its handler. The function receives two arguments:
+   *
+   *       result:
+   *         An object containing either a `handler` or an `error` property.
+   *         If no error occurs, `handler` will be a matching handler that
+   *         was registered by `addHandler`. Otherwise, the `error` property
+   *         will contain an object describing the error.
+   *
+   *        data:
+   *          An object describing the message, as defined in
+   *          `MessageChannel.addListener`.
+   */
+  constructor(messageName, callback, messageManager) {
+    this.messageName = messageName;
+    this.callback = callback;
+    this.messageManager = messageManager;
+
+    this.messageManager.addMessageListener(this.messageName, this);
+
+    this.handlers = new Map();
+  }
+
+  /**
+   * Receives a message from our message manager, maps it to a handler, and
+   * passes the result to our message callback.
+   */
+  receiveMessage({ data, target }) {
+    let handlers = Array.from(this.getHandlers(data.messageName, data.recipient));
+
+    let result = {};
+    if (handlers.length == 0) {
+      result.error = { result: MessageChannel.RESULT_NO_HANDLER,
+                       message: "No matching message handler" };
+    } else if (handlers.length > 1) {
+      result.error = { result: MessageChannel.RESULT_MULTIPLE_HANDLERS,
+                       message: `Multiple matching handlers for ${data.messageName}` };
+    } else {
+      result.handler = handlers[0];
+    }
+
+    data.target = target;
+    this.callback(result, data);
+  }
+
+  /**
+   * Iterates over all handlers for the given message name. If `recipient`
+   * is provided, only iterates over handlers whose filters match it.
+   *
+   * @param {string|number} messageName
+   *     The message for which to return handlers.
+   * @param {object} recipient
+   *     The recipient data on which to filter handlers.
+   */
+  * getHandlers(messageName, recipient) {
+    let handlers = this.handlers.get(messageName) || new Set();
+    for (let handler of handlers) {
+      if (MessageChannel.matchesFilter(handler.messageFilter, recipient)) {
+        yield handler;
+      }
+    }
+  }
+
+  /**
+   * Registers a handler for the given message.
+   *
+   * @param {string} messageName
+   *     The internal message name for which to register the handler.
+   * @param {object} handler
+   *     An opaque handler object. The object must have a `messageFilter`
+   *     property on which to filter messages. Final dispatching is handled
+   *     by the message callback passed to the constructor.
+   */
+  addHandler(messageName, handler) {
+    if (!this.handlers.has(messageName)) {
+      this.handlers.set(messageName, new Set());
+    }
+
+    this.handlers.get(messageName).add(handler);
+  }
+
+  /**
+   * Unregisters a handler for the given message.
+   *
+   * @param {string} messageName
+   *     The internal message name for which to unregister the handler.
+   * @param {object} handler
+   *     The handler object to unregister.
+   */
+  removeHandler(messageName, handler) {
+    this.handlers.get(messageName).delete(handler);
+  }
+}
+
+/**
+ * Manages mappings of message managers to their corresponding message
+ * brokers. Brokers are lazily created for each message manager the
+ * first time they are accessed. In the case of content frame message
+ * managers, they are also automatically destroyed when the frame
+ * unload event fires.
+ */
+class FilteringMessageManagerMap extends Map {
+  // Unfortunately, we can't use a WeakMap for this, because message
+  // managers do not support preserved wrappers.
+
+  /**
+   * @param {string} messageName
+   *     The native message name passed to `FilteringMessageManager` constructors.
+   * @param {function} callback
+   *     The message callback function passed to
+   *     `FilteringMessageManager` constructors.
+   */
+  constructor(messageName, callback) {
+    super();
+
+    this.messageName = messageName;
+    this.callback = callback;
+  }
+
+  /**
+   * Returns, and possibly creates, a message broker for the given
+   * message manager.
+   *
+   * @param {nsIMessageSender} target
+   *     The message manager for which to return a broker.
+   *
+   * @returns {FilteringMessageManager}
+   */
+  get(target) {
+    if (this.has(target)) {
+      return super.get(target);
+    }
+
+    let broker = new FilteringMessageManager(this.messageName, this.callback, target);
+    this.set(target, broker);
+
+    if (target instanceof Ci.nsIDOMEventTarget) {
+      let onUnload = event => {
+        target.removeEventListener("unload", onUnload);
+        this.delete(target);
+      };
+      target.addEventListener("unload", onUnload);
+    }
+
+    return broker;
+  }
+}
+
+const MESSAGE_MESSAGE = "MessageChannel:Message";
+const MESSAGE_RESPONSE = "MessageChannel:Response";
+
+let gChannelId = 0;
+
+this.MessageChannel = {
+  init() {
+    Services.obs.addObserver(this, "message-manager-close", false);
+    Services.obs.addObserver(this, "message-manager-disconnect", false);
+
+    this.messageManagers = new FilteringMessageManagerMap(
+      MESSAGE_MESSAGE, this._handleMessage.bind(this));
+
+    this.responseManagers = new FilteringMessageManagerMap(
+      MESSAGE_RESPONSE, this._handleResponse.bind(this));
+
+    /**
+     * Contains a list of pending responses, either waiting to be
+     * received or waiting to be sent. @see _addPendingResponse
+     */
+    this.pendingResponses = new Set();
+  },
+
+  RESULT_SUCCESS: 0,
+  RESULT_DISCONNECTED: 1,
+  RESULT_NO_HANDLER: 2,
+  RESULT_MULTIPLE_HANDLERS: 3,
+  RESULT_ERROR: 4,
+
+  REASON_DISCONNECTED: {
+    result: this.RESULT_DISCONNECTED,
+    message: "Message manager disconnected",
+  },
+
+  /**
+   * Returns true if the given `data` object matches the given `filter`
+   * object. The objects match if every property of `filter` is present
+   * in `data`, and the values in both objects are strictly equal.
+   *
+   * @param {object} filter
+   *    The filter object to match against.
+   * @param {object} data
+   *    The data object being matched.
+   * @returns {bool} True if the objects match.
+   */
+  matchesFilter(filter, data) {
+    return Object.keys(filter).every(key => {
+      return key in data && data[key] === filter[key];
+    });
+  },
+
+  /**
+   * Adds a message listener to the given message manager.
+   *
+   * @param {nsIMessageSender} target
+   *    The message manager on which to listen.
+   * @param {string|number} messageName
+   *    The name of the message to listen for.
+   * @param {MessageReceiver} handler
+   *    The handler to dispatch to. Must be an object with the following
+   *    properties:
+   *
+   *      receiveMessage:
+   *        A method which is called for each message received by the
+   *        listener. The method takes one argument, an object, with the
+   *        following properties:
+   *
+   *          messageName:
+   *            The internal message name, as passed to `sendMessage`.
+   *
+   *          target:
+   *            The message manager which received this message.
+   *
+   *          channelId:
+   *            The internal ID of the transaction, used to map responses to
+   *            the original sender.
+   *
+   *          sender:
+   *            An object describing the sender, as passed to `sendMessage`.
+   *
+   *          recipient:
+   *            An object describing the recipient, as passed to
+   *            `sendMessage`.
+   *
+   *          data:
+   *            The contents of the message, as passed to `sendMessage`.
+   *
+   *        The method may return any structured-clone-compatible
+   *        object, which will be returned as a response to the message
+   *        sender. It may also instead return a `Promise`, the
+   *        resolution or rejection value of which will likewise be
+   *        returned to the message sender.
+   *
+   *      messageFilter:
+   *        An object containing arbitrary properties on which to filter
+   *        received messages. Messages will only be dispatched to this
+   *        object if the `recipient` object passed to `sendMessage`
+   *        matches this filter, as determined by `matchesFilter`.
+   */
+  addListener(target, messageName, handler) {
+    this.messageManagers.get(target).addHandler(messageName, handler);
+  },
+
+  /**
+   * Removes a message listener from the given message manager.
+   *
+   * @param {nsIMessageSender} target
+   *    The message manager on which to stop listening.
+   * @param {string|number} messageName
+   *    The name of the message to stop listening for.
+   * @param {MessageReceiver} handler
+   *    The handler to stop dispatching to.
+   */
+  removeListener(target, messageName, handler) {
+    this.messageManagers.get(target).removeListener(messageName, handler);
+  },
+
+  /**
+   * Sends a message via the given message manager. Returns a promise which
+   * resolves or rejects with the return value of the message receiver.
+   *
+   * The promise also rejects if there is no matching listener, or the other
+   * side of the message manager disconnects before the response is received.
+   *
+   * @param {nsIMessageSender} target
+   *    The message manager on which to send the message.
+   * @param {string} messageName
+   *    The name of the message to send, as passed to `addListener`.
+   * @param {object} data
+   *    A structured-clone-compatible object to send to the message
+   *    recipient.
+   * @param {object} [recipient]
+   *    A structured-clone-compatible object to identify the message
+   *    recipient. The object must match the `messageFilter` defined by
+   *    recipients in order for the message to be received.
+   * @param {object} [sender]
+   *    A structured-clone-compatible object to identify the message
+   *    sender. This object may also be used as a filter to prematurely
+   *    abort responses when the sender is being destroyed.
+   *    @see `abortResponses`.
+   * @returns Promise
+   */
+  sendMessage(target, messageName, data, recipient = {}, sender = {}) {
+    let channelId = gChannelId++;
+    let message = { messageName, channelId, sender, recipient, data };
+
+    let deferred = PromiseUtils.defer();
+    deferred.messageFilter = {};
+    deferred.sender = recipient;
+    deferred.messageManager = target;
+
+    this._addPendingResponse(deferred);
+
+    // The channel ID is used as the message name when routing responses.
+    // Add a message listener to the response broker, and remove it once
+    // we've gotten (or canceled) a response.
+    let broker = this.responseManagers.get(target);
+    broker.addHandler(channelId, deferred);
+
+    let cleanup = () => {
+      broker.removeHandler(channelId, deferred);
+    };
+    deferred.promise.then(cleanup, cleanup);
+
+    target.sendAsyncMessage(MESSAGE_MESSAGE, message);
+    return deferred.promise;
+  },
+
+  /**
+   * Handles dispatching message callbacks from the message brokers to their
+   * appropriate `MessageReceivers`, and routing the responses back to the
+   * original senders.
+   *
+   * Each handler object is a `MessageReceiver` object as passed to
+   * `addListener`.
+   */
+  _handleMessage({ handler, error }, data) {
+    // The target passed to `receiveMessage` is sometimes a message manager
+    // owner instead of a message manager, so make sure to convert it to a
+    // message manager first if necessary.
+    let { target } = data;
+    if (!(target instanceof Ci.nsIMessageSender)) {
+      target = target.messageManager;
+    }
+
+    let deferred = {
+      sender: data.sender,
+      messageManager: target,
+    };
+    deferred.promise = new Promise((resolve, reject) => {
+      deferred.reject = reject;
+
+      if (handler) {
+        let result = handler.receiveMessage(data);
+        resolve(result);
+      } else {
+        reject(error);
+      }
+    }).then(
+      value => {
+        let response = {
+          result: this.RESULT_SUCCESS,
+          messageName: data.channelId,
+          recipient: {},
+          value,
+        };
+
+        target.sendAsyncMessage(MESSAGE_RESPONSE, response);
+      },
+      error => {
+        let response = {
+          result: this.RESULT_ERROR,
+          messageName: data.channelId,
+          recipient: {},
+          error,
+        };
+
+        if (error && typeof(error) == "object") {
+          if (error.result) {
+            response.result = error.result;
+          }
+        }
+
+        target.sendAsyncMessage(MESSAGE_RESPONSE, response);
+      });
+
+    this._addPendingResponse(deferred);
+  },
+
+  /**
+   * Handles message callbacks from the response brokers.
+   *
+   * Each handler object is a deferred object created by `sendMessage`, and
+   * should be resolved or rejected based on the contents of the response.
+   */
+  _handleResponse({ handler, error }, data) {
+    if (error) {
+      // If we have an error at this point, we have handler to report it to,
+      // so just log it.
+      Cu.reportError(error.message);
+    } else if (data.result === this.RESULT_SUCCESS) {
+      handler.resolve(data.value);
+    } else {
+      handler.reject(data);
+    }
+  },
+
+  /**
+   * Adds a pending response to the the `pendingResponses` list.
+   *
+   * The response object must be a deferred promise with the following
+   * properties:
+   *
+   *  promise:
+   *    The promise object which resolves or rejects when the response
+   *    is no longer pending.
+   *
+   *  reject:
+   *    A function which, when called, causes the `promise` object to be
+   *    rejected.
+   *
+   *  sender:
+   *    A sender object, as passed to `sendMessage.
+   *
+   *  messageManager:
+   *    The message manager the response will be sent or received on.
+   *
+   * When the promise resolves or rejects, it will be removed from the
+   * list.
+   *
+   * These values are used to clear pending responses when execution
+   * contexts are destroyed.
+   */
+  _addPendingResponse(deferred) {
+    let cleanup = () => {
+      this.pendingResponses.delete(deferred);
+    };
+    this.pendingResponses.add(deferred);
+    deferred.promise.then(cleanup, cleanup);
+  },
+
+  /**
+   * Aborts any pending message responses to senders matching the given
+   * filter.
+   *
+   * @param {object} sender
+   *    The object on which to filter senders, as determined by
+   *    `matchesFilter`.
+   * @param {object} [reason]
+   *    An optional object describing the reason the response was aborted.
+   *    Will be passed to the promise rejection handler of all aborted
+   *    responses.
+   */
+  abortResponses(sender, reason = this.REASON_DISCONNECTED) {
+    for (let response of this.pendingResponses) {
+      if (this.matchesFilter(sender, response.sender)) {
+        response.reject(reason);
+      }
+    }
+  },
+
+  /**
+   * Aborts any pending message responses to the broker for the given
+   * message manager.
+   *
+   * @param {nsIMessageSender} target
+   *    The message manager for which to abort brokers.
+   * @param {object} reason
+   *    An object describing the reason the responses were aborted.
+   *    Will be passed to the promise rejection handler of all aborted
+   *    responses.
+   */
+  abortMessageManager(target, reason) {
+    for (let response of this.pendingResponses) {
+      if (response.messageManager === target) {
+        response.reject(reason);
+      }
+    }
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case "message-manager-close":
+      case "message-manager-disconnect":
+        try {
+          if (this.responseManagers.has(subject)) {
+            this.abortMessageManager(subject, this.REASON_DISCONNECTED);
+          }
+        } finally {
+          this.responseManagers.delete(subject);
+          this.messageManagers.delete(subject);
+        }
+        break;
+    }
+  },
+};
+
+MessageChannel.init();
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -5,16 +5,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
+    'MessageChannel.jsm',
     'Schemas.jsm',
 ]
 
 DIRS += ['schemas']
 
 JAR_MANIFESTS += ['jar.mn']
 
 MOCHITEST_MANIFESTS += ['test/mochitest/mochitest.ini']